Precise error typing in Swift

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.

How would it be any different from locking a return value to a fixed type?

func getFoo() throws -> Foo

is homomorphic to

func getFoo() -> Result<Foo, Error>

Specifying a precise error type as the Result parameter would present the same sort of ABI stability challenges as the Success type. Am I missing something?

5 Likes

It’s not. Return types are part of the ABI. Opaque return types give implementations the ability to change their concrete return type as they evolve. Encouraging people to use more specific error types is a motion in the opposite direction. It increases fragility, which is tolerable only when you tightly control both sides of the interface.

2 Likes

I don't see it as moving in the opposite direction, so much as filling out the type-safety story of Swift. It has always struck me as odd that one of the main motivations for the creation of Swift was Objective-C's lack of type safety (id has entered the chat). Yet in Swift, we've somehow ended up in the place where we want descriptive types everywhere ... except with our errors?

Adding precise error typing would not stop anyone doing func getFoo() throws; we would still deal with any Error values just as we currently do. But for the API authors who can make guarantees about the kinds of errors we produce... why should we preclude that?

18 Likes

I’m not saying we should! I’m saying that even if the language gets the ability to specify tight return types, it’s actually not a good idea to use them in many real-world cases.

To give a concrete example: for years the only storage on an iPhone was local NAND. If Foundation were being developed concurrently in Swift, one might advocate for giving NSData’s file reading errors a more specific type, such as FileSytemError, since that describes the sole category of errors that could occur on an early iPhone.

Then the iPad came along, along with the Camera Connection Kit. Suddenly iOS devices could have removable storage. You could try to retrofit this concept into FileSystemError by extending it to cover the newly-possible case of a filesystem completely disappearing in the middle of access.

And then iCloud file providers appear. Suddenly your filesystem access can fail for network-related reasons. Do we extend FileSystemError to now cover all possible underlying network-related errors, and if so, do we duplicate NetworkError’s API into FileSystemError, or do we give FileSystemError the ability to “wrap” a NetworkError? Are any of these changes even possible to make in an ABI-compatible way?

In complex systems, API surface methods have much more control over the values they return on success than they do over the reasons they can fail. Swift’s current design, privileging concrete return types and abstract error types, reflects this.

3 Likes

Erasing to Error doesn't provide the stability you seem to think it does. Unless the errors are themselves entirely opaque as well you're going to run into the same ABI and source stability issues that you would if you returned an error type directly. Otherwise how do you expect people to change behavior depending on the specifics of the error? Unless all you care about is the fact that an error occurred, which try? already handles, you're going to cast to specific error types and look at the specific case information.

In any case, Swift already handles the example you give by supporting frozen enums. Evolving the underlying error in that case is as simple as adding cases to the enum, as the language has already required the use @unknown default by the user. Of course it would be nice if this feature could be adopted by non stable libraries for things like errors, but the biggest issue, ABI stability, is already solved.

In short, this benefit you claim for erased errors doesn't actually exist.

7 Likes

Even if your service accepts random errors over the network and it makes sense for those errors to be exposed like other runtime errors, it's almost always more valuable to specifically type your errors and erase when it's beneficial than it is to erase and attempt to recover when you need it. You could, of course, contrive a service that accepts any random error of any type from any connection, but those are extremely rare (and probably not good services). Even in cases where the errors provided are "undocumented", like with URLSession, you can still exclusively handle the vast majority of cases with only a little work, leaving a single fallback handler for the random error types that can still crop up.

At the end of the day it would be really nice to combine the ergonomics of throws with the precision of typed errors. One should not have to use Result to maintain typing.

2 Likes

Okay, we don’t actually need to have exactly the same conversation we’ve had a dozen times before in the context of a short response about the interaction of this prospective feature with distributed actors.

8 Likes

Same ABI breaking if you change your Result error type of any kind of method using Combine or just a return object.

Jorge Revuelta Herrero

What is the status of this? It was linked to by @hborla in the thread about Primary Associated Types in the context of getting support for them for AsyncSequence - which is something I'm very much looking forward to.

So precise error typing is potentially blocking PATs for AsyncSequence, and I got a bit worried that AsyncSequence<Element> support might be delayed a lot until we see some progress in this thread?

Thx!

6 Likes

I am not a compiler engineer or language engineer, but would it be possible for the compiler to make the error types implicit?

I would like the implicit annotation to extend to unions of errors too, not just throws to mean any Error - I imagine some developers may want to spell it out in the function signature, but some may prefer the ability to adapt to new error types without updating all signatures in their call stack.

For example:

func throwsA() throws {
  throw A()
}

func throwsB() throws {
  throw B()
}

func throwsAorB() throws {
  if Bool.random() {
    throw A()
  } else {
    throw B()
  }
}

...

do {
  try throwsA()
} catch { // error: A

}

...

do {
  try throwsB()
} catch { // error: B

}

...

do {
  try throwsAorB()
} catch let error as A { // error: A

} catch let error as B { // error: B

}

There’s a very active and much more recent thread about this topic. No need to resurrect one that has been dormant for a year.

Whoops, I thought I was there :man_facepalming: