Introduction
When error handling was added to Swift in 2015, it intentionally did not allow the expression of precise error types: either a function cannot throw, or it can throw any Error
. The error-handling rationale I wrote, which uses an awful lot of words to describe most aspects of the design, gives this one pretty short shrift:
There are many kinds of error. It's important to be able to recognize and respond to specific error causes programmatically. Swift should support easy pattern-matching for this.
But I've never really seen a point to coarser-grained categorization than that; for example, I'm not sure how you're supposed to react to an arbitrary, unknown IO error. And if there are useful error categories, they can probably be expressed with predicates instead of public subclasses. I think we start with a uni-type here and then challenge people to come up with reasons why they need anything more.
Thatâs it, other than some discussion in the survey of other languages. Now, that wasnât the last word: it's come up in a lot of Evolution discussions over the last six years. Iâve stuck to the position I first staked out then, but with some better arguments. One example is this email thread from 2017. In all, I've sketched out three main points against precise error typing:
-
Itâs not nearly as useful as you might think it should be on the caller side. Real error types almost always carry catch-all cases, or they include errors that never arise from the specific function you called, or theyâre just big, or all three. So the error type isnât really much of a hint about how the function can fail, and in practice people just call out a few cases that they know are important and handle the other cases generically, which you can do perfectly well without precise error typing.
-
Itâs strict, and that causes new problems given the nature of error handling. There's a trade-off between ease of composition of failing operations and the precision of error typing for those operations. In a perfectly precise world, every function would have a unique error type detailing why it failed and when. Every function would remap the errors thrown by the functions it called, considering what information it wants to expose. But this would be a disaster for fluency and composition. In practice, subsystems tend to share a common error type, which allows easy composition but leads immediately to those types becoming less meaningful to clients, with all the sins described above. Or worse, a function commits to the wrong error type, and it becomes impossible to express a new kind of failure without losing information.
-
Itâs a feature which encourages unproductive obsessions in programmers. Static typing leads many people to think that itâs always useful to be more precise with types. Usually thatâs quite true: using too broad a type, e.g.
Any
rather than anenum
, can create a lot of artificial difficulties. But error handling is different. Most functions have only a few novel sources of failure, and otherwise they just fail because some other function did. Composition, abstraction, and changing requirements often mean that the transitive set of âother functionsâ is so broad that it might as well be infinite. Dynamic typing models this well. Programmers who instead try to account for it statically find themselves repeatedly expanding annotations or adding new cases to types. It takes a lot of time without really making anything better.
But there are arguments in favor of precise error typing as well. Itâs been six years; we should look at those reasons and consider whether itâs time to add precise error typing to Swift. And thatâs what Iâm about to do.
Iâll spoil the conclusion: I think the answer is âyesâ. But itâs a âyesâ with a very big caveat. I think the strongest reasons for adding precise error typing relate to (1) the interaction of throws
with the generics system and (2) the requirements of low-level systems code. And so I think that we, as a community, should continue to strongly push imprecise error typing as the primary way that people ought to write code. Precise error typing will be a tool that programmers have in their toolbox for the cases where itâs really important (mostly, for low-level reliability and performance), and it will solve some expressivity problems for generic libraries. But when you don't need that tool, you should stick to throwing Error
.
I want to be clear that this is not a guarantee that the feature is coming, or even a plan of attack. The main reason I'm writing this is because it keeps coming up in proposal reviews: there are a lot of library features that need to decide whether and how to accommodate the possibility of precise error typing. So I think it's very important to have a conversation about where we're going with this, and I hope that starts now.
Error typing in Swift today
Swift has a form of typed error propagation in which functions simply declare whether they can or cannot throw. This imposes three interesting constraints, each of which is associated with a different argument for changing the system:
Staticness and polymorphism
The first constraint is that the decision is wholly static. You cannot decide at runtime whether or not a function is allowed to throw. That might sound sensible in the abstract, but abstraction is exactly why it isnât reasonable: any situation where you donât know statically what code might execute is a situation where the possibility of throwing can vary dynamically.
There are five basic ways in which code can change its behavior dynamically:
- Local control flow, such as an
if
orswitch
statement, based on a value that is statically unknown - Subclass polymorphism, in which an overridable method is called when the most-derived class of a class instance or metatype is statically unknown
- Functional polymorphism, in which a function value is called when the identity of the function is statically unknown
- Parametric polymorphism, in which a function is called that is associated with a type argument (e.g. because it witnesses a protocol requirement) and that type argument is statically unknown
- Environmental polymorphism, in which a function is called that is defined in the runtime environment (e.g. the OS) and the exact runtime environment is statically unknown
Now, we really do want to track whether something can throw statically, as accurately as possible, so that we can use that information to force errors to be handled in the right places without pedantically forcing them to be handled when theyâre impossible. So any solution has to become part of the "type system" on some level.
Letâs consider these five cases, then:
-
If a reachable part of a function can throw, calls to the function can throw. Completely unreachable code is a bug, so this is only interesting if code is dependently reachable, based on something we can know statically looking at the call. Type systems exist which can express things like âthis can throw if its
allowThrow
argument istrue
â: they're called "value-dependent", and they're such a huge leap in complexity that we'll just ignore them. Otherwise, it's hard to see local control flow having an external effect on the type. -
Overrides are allowed to drop
throws
, and that has to be honored by further overrides, so if you know that a class is a particular subclass, you can know that the method wonât throw. That already works, and itâs about as good as we can imagine for class polymorphism. -
Swift already makes a concession to functional polymorphism with
rethrows
. This even includes some control-flow sensitivity:catch
blocks are only reachable if thedo
block throws, and that can propagate therethrows
dependency. This is good enough for quite a lot of cases of functional polymorphism, but it does have two significant limitations. First, it only applies to direct references to function parameters; you can't have a struct method that'srethrows
on whether a function stored in the struct throws. This meansrethrows
functions can be artificially restricted because the checking just isnât clever enough. Second, thereâs no way to test dynamically whether an argument function throws, which would sometimes be useful. In any case, weâre not in a terrible position for this kind of polymorphism; itâs just more limited than weâd ideally like. -
There are two closely-related issues with parametric polymorphism and error propagation, both of which cause serious design distress. The first is that it's useful to say that a generic function might throw depending on the value of one of its type arguments: say, a function that maps
[Result<T, E>]
to[T]
throws unlessE == Never
. The second is that it's useful to say that a protocol requirement might throw depending on its conforming type; for example, thenext()
method on aReadableStream
might throw for a stream based on I/O, but not for a stream based on a stored array. Without this, either you need different functions, types, and protocols for the throwing/non-throwing cases, or you need to force throwing-ness on all generic clients. You can see these problems in the new concurrency library and its use of error arguments and ad-hoc overloads to try to eliminate unwanted throwing-ness. -
Environmental polymorphism always happens at an ABI boundary. Code should run correctly in future environments, and future environments can't become more throwing, so the only interesting interaction here is if a function promises not to throw as of some version, and we call it from code that's gated to that version or later. That would be straightforward to support if it ever becomes important; it doesn't need special consideration here.
Erasure and precision
The second constraint is that whether an error can be thrown is a boolean condition. Swift cannot express precise error typing, i.e. that a function throws only a specific kind of error; it can only express binary error typing, i.e. whether the function throws at all. As a result, the type system does not inform callers of the function what errors it can throw, and it does not constrain the implementation of the function to only throw specific errors.
This causes several problems:
-
Callers of the function cannot easily discover what kinds of errors can arise from it. If there are special conditions that perhaps they should handle specially, they are less likely to know about them without a close reading of the documentation (assuming it even covers that). They are more likely to handle errors generically, perhaps forgetting that it can fail for different reasons that are worth specifically accounting for.
-
Callers of the function cannot use language tools like contextual lookup (
.myCase
) or editing tools like code completion incatch
clauses. -
Callers of the function cannot provide special handling for all possible errors because
Error
is an unrestricted type. -
Implementors of the function may fail to process errors before passing them out. This might expose unwanted implementation details. It may also fail to provide useful context about the failure, such as that it happened during a particular stage of execution.
Against this must be weighed the arguments against precise error typing (which I also laid out in the introduction). I have long argued that programmers working with precise error typing tend to exercise the capabilities of that system in unproductive and frequently counter-productive ways. The fundamental reason is that most systems can fail in a wide variety of ways, and the set of possible causes of failure tends to grow over time as code evolves, becomes more capable, integrates with a larger set of systems, and so on. That is the nature of error handling, and it works against the claimed advantages of precise error typing. The exact effect differs depending on the kind of error typing in use, as we will see.
In systems like Java where multiple types of errors can be declared, functions tend towards one of two patterns:
- Functions list out all of the different ways they can fail, usually using types exactly as precise as their dependencies. As a result, whenever the dependencies change, the error list needs to change. If this is not possible, it becomes necessary to âsmuggleâ new errors through one of the old set of errors, or (in the case of Java) using an error type which doesnât have to be declared. Not only is this bookkeeping painstaking and disruptive, but it promotes bad patterns that undermine many of the supposed advantages of precision.
- Functions converge on listing one or two highly general error types, such as
java.lang.Exception
. My experience is that programmers often feel guilty for doing this, but canât really identify strong reasons why itâs bad, just that itâs âlosing informationâ because the given error type is vaguer than is strictly required. In practice, doing this creates less busywork and introduces fewer artificial problems without making it any harder to handle certain kinds of error specially.
In systems that force a single type of error to be declared, such as error handling built on top of a library monad like Result
, a different pattern emerges:
-
Functions use a specific error type, typically one thatâs shared across most (if not all) of the current component. Errors from dependencies are âwrappedâ into that error type. Most commonly, there is one case in the type for different underlying error types, so that the error type is effectively just a union of the different underlying types, plus possibly a few new failure cases originating from the library itself. Sometimes, the error type provides more context than that, either by storing extra information in certain cases or by using different cases for failures with different significance (e.g. arising at different points in execution). Even if so, there are usually catch-all cases which just embed uncontextualized underlying errors, and so (again) are at least in part simply unioned with those underlying error types.
While you can imagine using a different error type for each function, I have never seen anyone do that. Among other things, it forces clients to write different error-handling code for different calls, rather than composing monadically. As a result, the error type usually implies that certain errors are possible when in fact they can only arise from other functions that happen to share the error type, which undermines the supposed self-documenting nature of using a precise error type.
In any case, the error type is effectively unioned with multiple underlying error types, which are frequently themselves unions of underlying types; and often at least one of those options is a flexible supertype, much like
Error
in Swift. As a result, the discovery benefits of precise typing are also somewhat undermined, as it can be difficult to find interesting error cases when theyâre lost in a deep tree of library-specific error types. Additionally, because the type union is explicit via enum cases, matching against the cases of underlying errors can be very verbose.
Note that in all three patterns, actual practice tends to undermine the advantages claimed for precise error typing, and the precise typing converges towards an uncontextualized union of many error types, which is the base state of Swift with only imprecise error typing. Of course, itâs reasonable to object that itâs more than a little patronizing for the language design to reject expressive power on the basis that it is frequently misused. I can certainly imagine precise error typing being helpful. But I think at the very least these arguments caution us not to overrate the advantages of precise error typing, and they suggest that the use of imprecise error typing is not just a historical accident or legacy baggage. Even if Swift adds precise error typing, it will and should be common for programmers to work with Error
-throwing functions, and it is reasonable to expect that the basic recommendation for most Swift programmers will be to stick with throwing Error
unless you have a strong reason otherwise.
Because throwing Error
would remain important even if Swift adopted precise error typing, it is worth asking whether these problems can be addressed even for imprecise errors. Code completion, for example, isnât constrained to only take prompts from the type system; it would be quite useful to report interesting kinds of error that are merely listed in documentation. Similar approaches can be used for discovery. Such a search could descend recursively into function bodies, looking for the functions they call and so on, so that documentation doesnât need to be repeated at every level of abstraction.
As a final observation, I have never found the argument about exhaustive handling to be compelling. There are few systems with such a small number of possible failures that this would be practical, and there are few clients of such systems that would actually bother to do so, beyond generic actions such as presenting them for display that can still be done generically on Error
. Usually even such systems only have a few cases that need to be handled specially, and only at certain internal points, and then all others that should simply continue propagating, which is no different from non-exhaustive handling.
Erasure and representation
This is really just another consequence of erasure, but it is different in many practical aspects, and so I will deal with it separately.
The use of Error
as an error type means that error values must be âerasedâ into a generic error representation. This has three consequences for performance:
- An
Error
âboxâ may have to be dynamically allocated. Currently this is always done (except for already-allocatedNSError
references), even for trivial cases such as an enum case with no payload, because we must also store the type of the error in the box. - The box must store type metadata for the error type that has been thrown.
- Testing for a specific error requires a dynamic cast.
The slight upside is that we have designed our calling convention with the goal in mind of efficiently passing out an Error
box, so while it does require allocation and metadata, it doesnât pessimize normal calls.
The use of allocation and type metadata is particularly problematic for low-level systems/embedded programming, where we would like guarantees that such things arenât being used (to avoid the runtime costs and failure conditions of allocation, and to reduce code size, respectively). Precise error typing would avoid that by allowing the system to pass out an error in a more optimal form.
It is interesting to ask if this is a blocker for using natural-feeling Swift idioms in embedded environments. It may not be.
- Embedded environments could simply avoid the use of
throws
in favor of tools likeResult
. This is undesirable for the same reasons that Swift does not just always useResult
, which is covered perfectly well in the error-handling rationale. - Even if we offered precise error typing, there might be code in the standard library that would fall back on
Error
; either we would need to write optimizations that tried to eliminate the resulting boxing, or we would have to change the standard library to be generic over errors and change the conventions for such code to guarantee an absence of boxing. If we did have optimizations that eliminated boxing, we might be able to simply rely on them without adding typed throws. - We might be able to avoid boxing for trivial errors, perhaps by statically allocating the error box. If so, we could allow errors to try to allocate, but handle allocation failure for errors that do require boxing by substituting a preallocated trivial error. This might still be a workable programming model for embedded programmers.
In my opinion, these are at best weak workarounds. These low-level needs strongly motivate the addition of precise errors to the language, even if the default recommendation for most Swift development remains to just use Error
.
Design space of typed errors
Basic alternatives
There are three basic ways to try to solve these problems that I can see:
-
We can try to take the
rethrows
idea and apply it to generics and protocol conformances. This is a narrow attempt to solve the expressivity problems of error handling under parametric polymorphism. In my opinion, this is doomed to inadequacy, because it leaves us incapable of expressing more advanced relationships between types and throwing-ness, such as conditional throwing-ness. To express these relationships, we need to be able to write down error types. -
We can add binary throws clauses: either
throws
or non-throws
, but explicit in source. Probably the most natural (and extensible) way to do this would be to write a type afterthrows
, but force it to be one ofNever
,Error
, or an opaque type (such as a type parameter or associated type thereof). One immediate problem with this idea is that we canât currently constrain opaque types to be eitherError
orNever
, and in fact the places where we currently carry an error type argument (such asResult
andTask
) allow any type that conforms toError
. A more fundamental issue is that it doesnât solve anything except the generic expressivity problem; it doesnât help people who want precise typing, whether for expressivity purposes or for performance. -
We can just add precise throws clauses, where the type can be any type that conforms to
Error
. I have somewhat reluctantly come to the conclusion that this is the best approach for addressing the problems weâve identified. I believe we can effectively communicate that we see precise error typing as a tool to be used when required rather than something that should be adopted carelessly. We are adding an option for those who need it, not belatedly correcting a mistake and saying that errors should generally be precisely typed.As discussed earlier, I believe allowing multiple error types with precise typing ultimately just converges to using an
Error
-like supertype while wasting a lot of everyoneâs time. Using a single error type is much simpler as a language model, and it avoids a number of nasty implementation questions around representation and type-checking ofcatch
. The usability problem of easily embedding underlying errors into a precise error type could be addressed with a general enum-subtyping feature, although as I covered earlier I tend to think this sort of uncontextualized embedding is a bad pattern.
Type combinators
Both binary and precise error typing require the type system to be able to express a certain amount of type-level computation: a formula which resolves to some specific type based on its type arguments. These formula will, in fact, actually need to be spellable in source code.
At the very least, we require an error union, which takes two or more error types T_i and produces the composite error type thrown from a context that can throw any of T_i. This has simple computation rules, which happen to be a commutative monoid:
errorUnion(T, Never) == errorUnion(Never, T) == T
errorUnion(T, T) == T
errorUnion(T, U) == Error
(ifT != U
dynamically)
It falls out that errorUnion(T, Error) == errorUnion(Error, T) == Error
.
Note that this also has runtime components:
- We will need to be able to compute the result of
errorUnion
at runtime. This is just scanning a list of types for a non-Never
value and returning it if it is unique, or else returningNever
(if there are no non-Never
types) orError
(if there are multiple non-Never
types). - We will need to be able to inject component errors into an
errorUnion
. This is just a move, unless the component error type differs from the union type, in which case it must be the usual erasure intoError
(i.e. a box allocation unless the component type isNSError
).
Unfortunately, this may not be good enough if we want to be able to express rethrows
as a special case of precise error typing. Consider the following rethrows
function:
func foo(a: () throws -> Int, b: () throws -> Int) rethrows -> Int
Intuitively, we might want to express this as the following:
func foo<AError: Error, BError: Error>(a: () throws(AError) -> Int,
b: () throws(BError) -> Int)
throws(errorUnion(AError, BError)) -> Int
However, this is not correct, because rethrows
does not promise to only throw an error value that came from one of the argument functions. The correct typing is:
func foo<AError: Error, BError: Error>(a: () throws(AError) -> Int,
b: () throws(BError) -> Int)
throws(errorUnion(AError, BError) == Never ? Never : Error) -> Int
Modeling the existing behavior of rethrows
correctly will add significant complexity to this feature. It is not acceptable to not model that existing behavior, however; this is simply a cost which must be accepted to pursue this feature.
Expression type checker
Error checking is currently done as a post-pass on function bodies coupled with a purely syntactically-driven check that infers throwing-ness for closure bodies. The proposed approach cannot work this way; error type arguments can interact with the rest of the type system and must be resolved during the main type-checking pass. This has several consequences.
- Type-checking will need to track the error types thrown from expressions. This is likely in practice to be as a largely independent set of constraints from those introduced by normal type-checking, and so thereâs reason to hope that this wonât be a performance burden.
- Type-checking will need to be adjusted to apply generalized subtyping rules for function types with error types. This is not likely to be difficult.
- Type-checking will need to handle type combinators in the type-equality, subtyping, and conversion rules. This will take some doing.
- Type-checking will need to be able to infer the error type of a closure expression based on the throwing calls within the closure. This far exceeds what could possibly be achieved with a syntactic check; we will need to type-check the closure body simultaneously with the enclosing context. We have been doing work in that direction anyway, but it will be a blocker.
- Type-checking will need to be able to adjust the error type of a closure expression based on the presence of
do
/catch
blocks. This will require some careful design.
Calling convention
The current calling convention for errors relies on passing out a single pointerâs worth of data which is known to be non-zero when it carries an error value. To avoid boxing of errors, we will need the calling convention to instead allow an address to be passed in to be filled, and weâll need to come up with a way to signal that an error was thrown. Also, this convention will have to vary depending on what we know statically about the error type of a function, which is a new tweak on function-type abstraction differences. I think weâre fairly well set up for all this conceptually, but itâll require some novel ABI and SIL design and implementation work.
Resisting overly-precise error typing
As I've mentioned several times, I think the strongest reasons for implementing precise error typing in Swift are to solve the generic expressivity problem and meet certain low-level requirements. It is, of course, impossible to make a feature available to solve those low-level problems in general-purpose code without also making it available in situations that should probably continue to throw Error
. What can we do to resist this tendency?
First, note that it will continue to be quite a bit easier not to use precise error typing. Imprecise error typing will be the "default", available just by writing throws
, whereas precise error typing will require the programmer to include a type after throws
. Moreover, programmers using overly-precise error typing will probably find themselves catching and remapping errors a lot as they translate between layers using different types. Both of these points will create significant pressure against using the feature when it isn't needed. I'm not going to argue that we should deliberately make using the feature more difficult than necessary; I'm just saying that these innate costs do actually have some benefit in resisting its over-aggressive use.
We've also built a lot of momentum in the community around just throwing Error
. I'm aware that some parts of the community have embraced returning Result
instead of throwing specifically so that they can be more precise about error types, but you can't reach everybody; the vast majority of the community is happily using Error
, and we can underline that we still believe that's the right thing to do.
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. Precise error typing is a tool that you only reach for when you have a specific and important problem that it solves. That communication started years ago with all the prior Evolution discussion, and this document strongly supports it, and I, at least, will continue to push it at every opportunity.
Let me make that last point very clear. I strongly believe that precise error typing is a blunt instrument that usually introduces more problems than it solves. We should add it to the language despite that, because sometimes the problems that it solves do truly need to be solved, and precise error typing really is the only way to solve them.
Conclusions
I believe that Swift should move towards adopting precise error typing in order to solve fundamental expressivity problems with generics and allow the efficient returning of errors in low-level code. Unavoidably, some programmers will use that power in ways that I think are unwise, but I think we can resist that tendency with community guidelines and pressure.
If we pursue this feature, it will be a large amount of work. Most of that work will be novel and specific to this project, and it may not work out. It is also blocked on the successful implementation of multi-statement type-checking, which is itself a significant project whose success is far from certain. But I do think that precise error typing is a feature we should try to make part of the future direction of Swift.