Precise error typing in Swift

Slightly off-topic, I don't want to derail the thread from it's main topic.

One way some Swift community expressed a possibly solution for this problem is an enum with variadic generic type parameters, that itself would possess indexed cases just like tuples or even labels iff generic type parameter will support this in the future.

I'm going to use A | B syntax as a shorthand for such special enum.

let value_1: A | B = ...

switch value_1 {
case .0(let a): // A
  ...
case .1(let b): // B
  ...
}

let value_2: A | A = ... // still valid!

// maybe in the future
let value_3: (x: A | y: B) = ...

switch value_3 {
case .x(let a): // A
  ...
case .y(let b): // B
  ...
}

Such an enum is NOT a OneOf type, but fairly close and possibly still within the Swift's standards.

3 Likes

Could you elaborate on this constraint? If we want to encourage users to switch most uses of rethrows over to the "trivial" signature which just forwards the generic error parameter, why is it a requirement to support the expression of proper rethrows semantics with the typed throws feature? Would it not suffice to say "we expect future code to just forward the errors directly, but if the precise semantics of rethrows are desired, it will continue to be supported (though no future evolution should be expected)"?

The main thing you can express in rethrows that you otherwise can't is something that conditionally throws but changes the error type. I think there are two reasons we might want to support that:

  • It's still useful to be able to write that kind of combinator with precise error types.
  • It's nice to have a single set of rules built around common concepts.

I agree that it could probably be subsetted and the complexity left as an internal implementation detail.

3 Likes

Instead of introducing errorUnion which is specific for combining error types,
I personally would like to see more general (Open) Union Types to be introduced in Swift first,
which adds more value in error handling.
(NOTE: Union types natively exist in TypeScript, and recently Scala 3)

For example:

// Swift's (Closed) Enum
enum MyError: Error { case process1Error, process2Error }

// BAD: Using poor error domain which already mixes 1 & 2.
func process1() throws(MyError) { ... }
func process2() throws(MyError) { ... }
func process1And2() throws(MyError) { ... }

can be improved using Union Types:

struct Process1Error: Error {}
struct Process2Error: Error {}

// GOOD: Error domains are nicely separated per func and then combined.
func process1() throws(Process1Error) { ... }
func process2() throws(Process2Error) { ... }
func process1And2() throws(Process2Error | Process2Error) { ... }

so that error union will be expressed seamlessly as Process1Error | Process2Error
rather than errorUnion(Process1Error, Process2Error).

In this case:

will need a change to:

errorUnion(T, U) == T | U

which I think is more precise error typing for Swift.

3 Likes

There is of course a middle ground, that is, anonymous enums, that would be for enums what tuples are right now for structs.

I'll not go down this path again, I tried to show their utility in the past but given that they would represent the dual structure to tuples, they are pretty clearly useful for the dual reasons. Being able to sum multiple error types without the need to create a specific enum only for that is just another example. The most compelling arguments I read against this are mostly related to clarity of syntax, but for injection/projection we could actually use ._0, ._1, which would match the synthesized Codable keys recently added to Swift. Anonymous enums would follow the same protocol conformance rules we're adding for tuples, that is, Equatable, Hashable, Comparable, and we would need to add Error if all cases are Error.

Multiple Either# types added to the standard library would also work, but would be worse in terms of flexibility (no option to declare specific tags for cases), and would essentially represent a load of boilerplate.

2 Likes

We're not going to discuss anonymous enums or union types in this thread. If there are more posts about them, I will just move all of those posts into a new thread.

10 Likes

May I offer a syntax suggestion to address the tension between needing typed throws, but not wanting typed throws everywhere?

If typed throws where spelled differently, they would stand out. They would be easy to find and build linter rules for. Documentation for typed throws could mention their use cases along with a warning for the anti patterns they have.

I'm not sure if this is the time or place for this suggestion, forgive me if it isn't.

Adding keywords to solve a design problem is something to be careful with; otherwise the language becomes bloated. For low level tools adding keywords might be warranted.

A typed throws could be named throwsWithType and similar for rethrows etc.

I'd like to put into the table the proposal made some time ago that was rejected over the discussion mainly because lack of people with resources and enough knowledge about the complier to bring this into a formal proposal and the negative from done members of adding this, but being, as an author of it, as much concise and detailed as I was capable of with the help of many members to bring this thread into this discussion.

Great to see this topic being brought into discussion.

2 Likes

Vague error typing is the cause of some of the worst correctness bugs we've had with Swift, so I welcome anything that makes it stronger.

The situation we got into trouble with is one where the consumer of our APIs really had to care which error was returned. Broadly, if it was a fatal error, or a transient one, though there were more complexities. There were two problems:

  • We couldn't statically verify that the correct error type was thrown from our public APIs; anyone could write try anywhere in our codebase and throw an incorrect type to a consumer.
  • Clients couldn't know which error types and variants each of our APIs could throw; they were forced to have a catch-all clause with generic handling. It was very easy to let something important fall into the catch-all clause.

We eventually rewrote this code with explicit Result return types to avoid these problems. Not as ergonomic, but worth it for the correctness.

Both of these are solved simply by allowing throws T on a function, with the associated benefits — our code can't throw the wrong error type, client code knows exactly what errors must be matched.

There might be moments of awkwardness — we'd have to catch and force-cast around a throwing map in our codebase, for example. But it's surmountable kinds of problems.

The rest of what you're proposing is interesting and all, and probably eventually required, but I think it understates the value of the "simple" solution.

1 Like

I’m delighted to read this post as it clarifies a lot of question marks I had over Error handling in Swift.

Picking up on one of your points, John:

That ties in the third and most important point, which that I think we can effectively communicate in the community that we think that imprecise error typing continues to be the best default practice.

It seems to me that the way to do this is to convince developers that there is nothing to gain from precise typing.

As I mentioned in my own post on Error handling, I was definitely one of those caught out by using overly precise Error types where there needn’t have been.

Part of it was dogmatic – somehow it seemed like the ‘right’ thing to do.

Reading this and your previous post clears up most of that.

However, the other part is one of context. Not context for the compiler or for control flow, but context for me, the developer.

The one utility of a mega union type is that it effectively encodes a call stack of where and how that error takes place.

For many developers used to analysing the call stack of a crash report, this is valuable information that can serve to help them determine the conditions in which a particular, lesser spotted bug may arise in production.

How then, do we get the same developer context for an unexpected thrown Error?

It seems Cocoa used to handle this with an underlyingError property which could then be traversed and logged to provide a journey up the stack which the developer could use in their debugging efforts.

And perhaps that’s the one remaining utility of a mega union Error type in Swift. It can be traversed much like underlying Error to give developers insight into where there may be hotspots of concern.

A solution for this may go some way to convincing developers that their inclination for constrained Error types is now truly redundant.

My ideal solution would be for Errors to carry context with them, up the call stack, so that at the application level I can ‘log’ any unexpected/unhandled errors into something similar to a crash report. An ‘unhandled error’ report perhaps, that provides me with more detail then traversing a union type ever could.

2 Likes

I agree with the sentiment and would strongly discourage error union-like constructs. Either you're fully precise and exhaustive (and designed your error types well ahead of time) or you're not. Most code should use imprecise errors. A "case other(Error)" present in a "precise error" enum is a worse formulation of just throwing an imprecise error. Most use cases that think they want precise errors want imprecise errors.

In general, there are more ways for a program to misbehave than behave well and more ways of misbehaving can suddenly arise, inconvenient to release schedules or stability guarantees. It's natural to try to use the type system to classify well-behavior of programs, but misbehavior is particularly resistant to taxonomy.

I think we want only the "leafiest" of leaf functions to throw precise errors, and we want the language to easily and implicitly promote them to imprecise errors for propagation upwards, at least in the common case.

A related implementer problem is that if the docs call out how to handle the thrown error (e.g. saying it's always of some type), that's in practice a source and binary compatibility concern which is entirely missing from the source code. An implementer (such as a future maintainer) may think "of course I can throw some other error, that's the whole point of it being an existential" while actually introducing a subtle compatibility break.

That is, fully documenting the errors thrown is akin to establishing a "precise error" ABI contract without any enforcement or help from the compiler or type system.

Let's get concrete then. String(decoding: myCol, as: UTF8.self), since it's a non-throwing non-failable initializer, will perform encoding error-correction on the input while constructing the string (this is a totally sensible default). That does mean that the resulting String's UTF8View might have different contents than the input collection, and there are some clients for which this is relevant.

We'd like to have an initializer that will validate the input and throw if there is an encoding error. The thrown error should contain the Range in input indices spanning the first detected encoding error.

Imprecise error throwing does a disservice to both the library maintainer (i.e. me, for reasons mentioned above about establishing unenforced stability constraints) and on the caller of the API, since they will have to always code for some "unknown" case even though they chose this API for its precision and exhaustiveness. Thus we are more likely to add it as static func create(...) -> Result<String, EncodingError>, but that doesn't fit the style of the stdlib and Swift elsewhere.

And of course, clients of such a precisely-throwing initializer should be able to just say try and have it implicitly propagate the error upwards as Error (at least, when propagating out of an imprecise throwing function).

In my mind this is very similar to open vs frozen enums. Some enums permit exhaustive handling, and they're practically a different animal than open enums.

These optimizations are blocked by separate compilation, which for System is precisely where you want precise and unboxed errors. This is why System does the whole always-emit-into-client throwing function calls a usable-from-inline Result-returning function.

Is it essential that we infer precise error types or can we just infer as Error if there is no incoming or explicit type context? I.e. similar to how an integer literal, absent any context, will resolve to be Int.

Similarly, for errorUnion(T, U) above, is it essential to only be equivalent to Error when T != U dynamically, or should it be equivalent to Error when T == U cannot be proven statically?

It seems like precise errors really should be carefully considered and their usage likely intersects with API or other places where explicit types are listed.

An implicit conversion from precise to imprecise Error would further encourage this. E.g. a function foo() throws can try to call a function bar() throws(MyError), and MyError would propagate out as Error when leaving foo. edit: this falls out naturally

8 Likes

For what it’s worth, in whatever language I have used I have often dreaded error handling. Though the ergonomics of Swift error handling are the nicest I know off. :+1:

Still even with Swift dealing with unknown errors can be paralysing. For example the close function in FileHandle throws. I have no idea what kind of error could possible show up. So I don’t know what to do. I just want to close the file. What could possible go wrong with that? Should I just ignore the error? Should I catch it and then do…what exactly? It’s not like I care about it anymore. I closed it right, or did I? :thinking::flushed::scream: I did, right, right…

Sure I can imagine some things that could go wrong with opening a file but I don’t have an exhaustive list of it. Maybe some errors I want to handle differently than others but it’s all so vague on what could happen. Cross my fingers and hope for the best?

Reading this thread that specific errors should be discouraged is a bit surprising. Shouldn’t I know which errors could occur in order to react appropriately to them? File locked or file does not exist are different cases. I understand that lots of things can go wrong and all those errors should be dealt with. But sometimes it would be nice to know (some of the ) things that could go wrong just for peace of mind.

PS. I only want to convey how I experience error handling by means of some rhetorical questions.

16 Likes

For example the close function in FileHandle throws. I have no idea what kind of error could possible show up. So I don’t know what to do.

For me, these are the kinds of unrecoverable errors that you should a) log, so that you have some kind of record if it becomes an unexpected hot path in production, b) display some kind of ‘unknown error’ to the user with the option to send a report. (It’s impossible to provide specific messaging for the infinite set of unknown errors.) And c) possibly force a crash if the program is in some undetermined state where continuing execution may do more harm then good.

Both A and B require the ability to gather developer context for thrown Errors. Unfortunately, I think the capability to do this simply, and mindfully of the advice above, is currently missing from Swift.

2 Likes

Just want to +1 this behavior.

Do I miss something, but what's so special about this?

func bar() throws(MyError) {}

// same as `func foo() throws(Error)` 
func foo() throws {
  try bar()
}

MyError is a subtype of Error so the concrete error type is just erased to Error if bar throws and leaves foo.

1 Like

I agree, the conversion from a typed throws to an untyped throws seems to simply fall out of existing rules. The only way this wouldn't happen is if we added rules to explicitly prevent it. I can't think of any reason to do so. Nor has anyone proposed such a thing.

That's right, this should fall out naturally.

This looks like what you’re talking about, and you don’t need typed Errors for that:

1 Like

If there’s tooling support to warn when throwing an undocumented Error then does that solve this problem?

it depends. in some cases, say, you are implementing a manual safe save by creating a second file in a temp location, writing the contents of the first file to the second, doing extra modifications to the second file, closing the second file, calling exchangedata on both files to swap their contents and finally deleting the file in temp location. if close returns error - the whole operation fails (you should still attempt deleting the temp file), and you can show the "can't save file" dialog.

in regards to the error, as a user i'd appreciate to know what went wrong. "disk full" or "disk corrupted" or "file is write protected", or perhaps "file was modified by another process" - in all those cases i'd like to see the reason to know where to go to fix the issue. most apps these days don't bother doing this.

3 Likes