Precise error typing in Swift

Right. So I think we're all agreed then that using fatalError(_:) as a surrogate for gleaning developer context is heavy handed. But that brings us back to whether the status quo with regards to debuggers and logging might be leaving developers wanting with regards to failure context.

During development, the debugger is perfectly sufficient. But in production we must lean on logging.

So, currently, we must hope that the Error we catch provides enough context for us to identify, diagnose and solve any issue that might arise during the execution of our program in a production environment.

My feeling is that many developers will achieve this by wrapping errors, to leave a breadcrumb trail of where in the stack an issue has arisen. It feels like a 'good enough' solution for logging failure context. They might do this by adopting an underlyingError: Error? member in their module's own base error type, or perhaps they might be tempted to create a union type of all their sub-modules errors to achieve the same thing. And if I understand the original post correctly, is what we're trying to avoid.

So, if we are to convince developers that they don't need to wrap their errors. How do we give them a breadcrumb trail?

1 Like

I just read the initial post (but only once and quickly) and for now, I'm confused.

My main takeaways are:

  • Precise Error Types are bad for application-level programming
  • Precise Error Types are possible though and should be considered for low-level programming

Before I re-read the long text over and over again to understand it better, can maybe someone explain to me in simple terms why the following readFromFile function from one of my apps should better not use a precise error type?

public enum ReadWriteFileError: Error {
  case fileToReadIsDirectory(fileUrl: URL)
  case fileToReadDoesNotExist(fileUrl: URL)
  case foundationReadFromFileError(fileUrl: URL, error: Error)
  case typeDecodingError(error: Error)
}

enum SomeCodableType {
  /// - Throws: ``ReadWriteFileError``
  public static func readFromFile(at url: URL) throws -> SomeCodableType {
    let (fileExists, fileIsDirectory) = FileManager.default.fileExistsAndIsDirectory(atPath: url.path)

    guard fileExists else { throw ReadWriteFileError.fileToReadDoesNotExist(fileUrl: url) }
    guard !fileIsDirectory else { throw ReadWriteFileError.fileToReadIsDirectory(fileUrl: url) }

    do {
      let fileContentData = try Data(contentsOf: URL)

      do {
        return try Self.decoder.decode(Self.self, from: fileContentData)
      }
      catch {
        throw ReadWriteFileError.typeDecodingError(error: error)
      }
    }
    catch {
      throw ReadWriteFileError.foundationReadFromFileError(fileUrl: url, error: error)
    }
  }
}

Although my function guarantees that I can only get a ReadWriteFileError when calling this function, I still have to add a catch-all to make the compiler happy when using it, because this guarantee is only documented, not communicated to the compiler.

I understand that we should not use precise error types everywhere, but in some places I can imagine they make a lot of sense in high-level code, too ... what am I missing?

2 Likes

Do you intend for this enum to be @frozen or not? If it's not frozen, then users will still need a fall-through for unknown cases. If you want to add @frozen but keep the ability to throw new kinds of errors, you could add a case unknown(Error), which lands users in the exact same boat but with an extra level of error wrapping. If you wanted to leave the enum exactly as-is, it's still the case that thrown foundation errors and type decoding errors are wrapped up in an extra layer instead of just throwing them directly.

I'm not saying it's bad to throw a precise error from a file read function, but for libraries with any concern of stability (source or binary), it can be non-intuitively foot-gunny.

3 Likes

I understand the framework argument. But this isn't code for a framework, it's already at the end of the chain: It's code inside an app project. I don't need to make my type frozen or add an unknown case, because when I change the enum (= add new cases), I can also change all callers of the enum (= the app code). And for this task, I'd actually be happy if the compiler failed to build the call site when I add a new case, because then I know exactly where I need to make changes.

4 Likes

Why is it public? That's what threw me. If you have no users other than yourself, then there are fewer problems. I think the argument for non-public typed throws is very strong, it's when you actually have users that things get complicated.

2 Likes

As long as the language offers an easy, automatic way to erase the error, I really don't see the problem here. As a framework author I'd rather offer precise errors the user can erase with a standard mechanism than erased errors the user has to recover by knowing about my error types.

IMO, it's simple enough to have throws and throws ErrorType, where any throw of a typed error from within a throws function will automatically be erased to Error.

22 Likes

It's public because I'm modularizing my app. See here: Episode #171: Modularization: Part 1

5 Likes

This is the main reason I want precise errors: they make reasoning about specialized micropackages far easier.

11 Likes

+1000 on this - I work heavily on both local and shared Swift packages. I'd love to be able to provide async APIs with strong error type guarantees - currently I have to use Combine or Result types for this.

Will the consuming clients erase the errors downstream in their error handling pipelines? Maybe. But at least I can give them an API error contract that's enforced by the compiler.

15 Likes

Even if the throwing function is in a public library, and even if the typed error enum isn't @frozen, it's still adds value for framework users, non? It gives me a great starting point to discover common, high-level errors that may require special handling or user interactions.

I'd much rather have the compiler tell me that the common errors are file-not-exists, file-is-dir and that I also have to deal with @unknown errors, rather than having no help at all.

14 Likes

I just wrote a pitch kinda related to this thread (it's about Result, actually), see here:

2 Likes

Are there still plans or work in progress for this? This, the @rethrows attribute, and the lack of a primary associated type for AsyncSequence means that certain APIs are currently impossible or unwieldy. Take for example a redux-like system with the following protocol.

protocol Reducer<State, Action> {
  associatedtype State 
  associatedtype Action

  func reduce(state: inout State, action: Action) -> any AsyncSequence<Action, Never>
}
1 Like

There hasn't been any concrete action here, no; the team has been wrapped up in other work.

One thing that caught my attention recently was the distributed actor implication for error handling.

A non throwing distributed func foo() on a distributed actor becomes throwing. Does it mean that a distributed operation cannot ever have a concrete error type and must erase any error (if any) to Error?

Follow up questions. Now that we have any and some:

  • do we now throw any Error?
  • could we have opaque errors and throw some Error with typed error feature?
1 Like

In principle, I think the ad hoc checking for distributed code could merge the error types from the actor method and the actor's chosen transport protocol, so if they agreed on a non-Error error type, you wouldn't have to erase all the way to Error.

That said, a network service seems like exactly the sort of situation for which I have argued before that trying to lock down the error type is usually counter-productive, and you are better off just using Error.

10 Likes

Locking down error types across network boundaries is a mess imho and counter productive. but if we had to be more precise that’s why the distributed module includes a “DistributedActorSystemError” protocol to which errors thrown by the actor system (transport) should conform… in theory then actual errors then a would be “SomeErrorTheFunctionThrows | DistributedActorSystemError” but I know we’re missing union types and there did not seem to be much love for them in the team, at least last time I mentioned them.

In concrete runtimes (the cluster) we even require allow-listing error types before we try to encode them, as to not accidentally leak information to remote callers.

Having that said, the less tied up remote peers are in exact impl details of another the better; and especially errors are not that helpful imho.

1 Like

This recalls the discussion and proposal made here time ago. With the introduction of Concurrency, consumers keep losing information about error handling and it's inevitable in a way or another for Swift to provide a visible error handling interface as it does with Result and Combine.

3 Likes

It’s not clear why you think it’s “inevitable”?

It's my own opinion but the current throw system is totally erasing sufficiently error information wether his rivals are specifically typing it. There are technical reasons to go for Concurrency, but error management is not over of them vs its competitors that, surprisingly, were added when the language got certain maturity. Why should I have to choose between typed or not typed error handling depending on the framework or API that I'm consuming? There are many explanations of this in the linked pitch so I think I shouldn't extend much further about my opinion.

There’s a difference between losing error information and not exposing it in the type system. Swift wants you to handle things like enum cases exhaustively. It would be a serious ABI stability challenge if APIs were locked to a fixed set of error types forever.

When you control both sides of the ABI (like the interface between your model and view code), it’s not as big of a deal.