Precise error typing in Swift

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 an enum, 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 or switch 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 is true”: 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 the do block throws, and that can propagate the rethrows 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's rethrows on whether a function stored in the struct throws. This means rethrows 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 unless E == Never. The second is that it's useful to say that a protocol requirement might throw depending on its conforming type; for example, the next() method on a ReadableStream 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 in catch 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-allocated NSError 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 like Result. This is undesirable for the same reasons that Swift does not just always use Result, 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 after throws, but force it to be one of Never, 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 either Error or Never, and in fact the places where we currently carry an error type argument (such as Result and Task) allow any type that conforms to Error. 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 of catch. 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 (if T != 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 returning Never (if there are no non-Never types) or Error (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 into Error (i.e. a box allocation unless the component type is NSError).

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.

84 Likes

Thanks for the great writeup, John!

You cover all the "against" cases I'd have brought up: verboseness and deep "trees" when errors try to be too precise, locking-in API too aggressively causing workarounds and undermining the precise errors etc. I very much agree with the notion that the "exhaustiveness error handling" is over-sold a lot, in practice there's almost always some "and all the other cases..." or some room for future evolution etc.

I have some thoughts about type unions in general that would make precise throws a bit more "bearable" if we really had to have them... but I'll leave that for a day when we actually get to discussing exact designs.

Again, thank you for highlighting this point repeatedly that it really is a tricky and blunt tool and not a magical solution to things as some threads sometimes lead people to believe... :slightly_smiling_face:

It'll be tough, but at least possible to steer libraries and communities towards doing the right thing thanks to such strong messaging around this, thank you! :clap:

4 Likes

I just read to this part and boy I couldn't agree more on this. This is exactly what I personally always wanted from this feature! Thank you John.

2 Likes

While reading this post I had some questions on my mind.

  • Why is it errorUnion and not commonSuperError?

    • commonSuperError(T, Never) == commonSuperError(Never, T) == T (Never would need to be subtype of T and Error aka. bottom type.)
    • commonSuperError(T, T) == T
    • commonSuperError(T, U) == X (if X: Error and T: X, U: X otherwise X will be Error)
    • commonSuperError(T, Error) == commonSuperError(Error, T) == Error (This is implied from above rules)
  • Should errorUnion / commonSuperError be a generically variadic function?

  • Would it be possible to split rethrows and make it so rethrows and throws could co-exist?

The error union part could become opaque to the user. If your function truly needs to throw an error, you will need to explicitly specify that intent, otherwise it will only rethrow (if specified).

func foo<AError: Error, BError: Error>(
  a: () throws(AError) -> Int,                          
  b: () throws(BError) -> Int
) throws rethrows<AError, BError> -> Int

// foo always throws Error because `throws`
// is not a concrete error 
// commonSuperError(Error, AError, BError) is implied

func bar<AError: Error, BError: Error>(
  a: () throws(AError) -> Int,                          
  b: () throws(BError) -> Int
) rethrows<AError, BError> -> Int

// bar only rethrows
// commonSuperError(AError, BError) is implied

func baz<AError: Error, BError: Error>(
  a: () throws(AError) -> Int,                          
  b: () throws(BError) -> Int
) throws<CError> rethrows<AError, BError> -> Int

// baz always throws
// commonSuperError(CError, AError, BError) is implied

Also note that such rethrows would be variadic and have 1-N error type parameters, depending on how many of the function paramters it should rethrow.

I think this would, at least, improve the UX of such feature.

1 Like

Excellent write-up @John_McCall! Some really interesting points to consider.

WRT to low-level performance, the pattern that I've been using is for libraries to erase their error types within an inlinable function:

@inlinable
public func myFunc() throws -> Int {
  myFunc_impl.get() // compiler knows 'get()' will throw a `MyError`
}

@usableFromInline
internal func myFunc_impl() -> Result<Int, MyError> { ... }

As I understand it, the compiler can see the specific type of error being thrown, so it can (I don't know if it does) avoid the costs of erasing to Error that you mentioned (allocating the box, storing type metadata, etc). However, at the source level, the client developer will still need to handle the possibly that a different kind of error will be thrown in the future, because the library author hasn't committed to a specific error type.

This assumes there is no resilience barrier between the library containing the above functions and the caller, but I think that's a reasonable assumption if you're looking for low-level performance.

I'm not sure this point is convincing. If we're worried about embedded environments always using Result, the implication seems to be that we'd prefer them always using typed throws... but also that we don't want developers always using typed throws.

3 Likes

I am very glad you bring this up as part of the this write-up, and—while I can understand that it isn’t within the bailiwick of the same folks who would be working on the type system aspects of this problem—I would go so far as to say that it’s worth asking these questions as part of this conversation.

Later, you argue that the naturally more verbose experience of precisely typed errors, community momentum, and advocacy are the ways in which best practices can promoted. Yet these are all happening today, and the idea that it would be sufficient to promote best practices is in tension with the observation that users who are not trying to write low-level code or having generic expressivity problems have been prominent advocates for typed throws and have been expressing their APIs in terms of Result. Where this pattern is applied to libraries, it will have knock-on effects on the ecosystem; users work with products and adopt practices not just from the core team but from other practitioners of the language.

Therefore, I think it would be essential as part of this design work to deliver for untyped errors some of the principal benefits that users ascribe to precisely typed errors that are actually not tied inextricably to them, such as code completion and discovery.

To be clear, these improvements can certainly stand on their own; my argument would be that we ought to be seriously concerned about introducing precisely typed throws without these improvements to untyped error handling. We would risk being in the position of acknowledging the usefulness of code completion and discovery benefits while advocating for users not to reach for a feature that actually enables them, which in my view is a losing argument. Users would be right to be skeptical of forgoing such benefits for their code in the moment by adopting typed throws, based on promises that it will be possible to have such benefits some day with untyped throws.

12 Likes

This will be a good approach if the compiler starts generating warnings when you don't document using - Throws. People don't, and haven't, used it.

The way it's handled right now even suggests abandonment.

/// Write the contents of the `Data` to a location.
///
/// - parameter url: The location to write the data into.
/// - parameter options: Options for writing the data. Default value is `[]`.
/// - throws: An error in the Cocoa domain, if there is an error writing to the `URL`.
public func write(to url: URL, options: Data.WritingOptions = []) throws

/// Decodes a top-level value of the given type from the given JSON representation.
///
/// - parameter type: The type of the value to decode.
/// - parameter data: The data to decode from.
/// - returns: A value of the requested type.
/// - throws: `DecodingError.dataCorrupted` if values requested from the payload are corrupted, or if the given data is not valid JSON.
/// - throws: An error if any value throws an error during decoding.
open func decode<T>(_ type: T.Type, from data: Data) throws -> T where T : Decodable

6 Likes

Likely because It's not generated by Xcode when using it to generate the doc comments. Alamofire doesn't have many throwing APIs so I forget this option even exists.

1 Like

I think the motivation needs a lot more fleshing out.

Quote snippets of listed motivation

The motivation as written constitutes a lot of “telling”, and essentially no “showing”. The write-up contains zero examples of a function where the author believes the proposed feature would be beneficial.

In order to evaluate the idea, it would be quite helpful to see a single, strong, motivating example where the author believes that typed errors should be used. The example should be as strong as possible, ideally an existing real-world function.

And the motivation should explain what the existing alternatives are, and how the proposed feature is superior. After all, a low-level function that wishes to avoid allocating, can already choose a variety of ways to indicate when an error occurs.

Only then, when we have all the information, can we productively discuss whether the benefits outweigh the costs.

3 Likes

This isn't a pitch, so yes, it's purely an opinion piece, filled with the arguments that have shaped my thinking about it.

8 Likes

I should probably have been more clear — this isn't a concrete pitch for the language feature, and all the syntax I used in my examples is placeholder.

I don't know that I want to promise that commonSuperError (using your name) is actually something like a subtype join.

I think it's best to think of rethrows as sugar for a particular common situation — albeit sugar with a slightly different ABI — rather than formalizing it as an orthogonal concept. I'm not really sure what it means to "rethrow" AError that's distinct from just throwing AError. A thrown AError doesn't have to come from a specific function in general (except in a "theorems for free" sort of way, which doesn't seem to contribute much to the design).

We could allow you to write throws (AError, BError, CError) and treat that as sugar for throws (commonSuperError(AError, BError, CError)); that does seem like a nice improvement. I didn't really mean this as a pitch, so I didn't think about the specific syntax very closely.

2 Likes

If we need union types for error handling, could we introduce this as general-purpose support that supports any types? (e.g. support for any union types like Int | String, including T | Error)

The Commonly Rejected Changes list seems to mention union types:

  • Disjunctions (logical ORs) in type constraints: These include anonymous union-like types (e.g. (Int | String) for a type that can be inhabited by either an integer or a string). "[This type of constraint is] something that the type system cannot and should not support."

Type unions have always seemed like a broadly useful idea to me, and I'm not familiar with the background/context around prior discussions. T | Error seems like a natural and precedented spelling for a feature like this (that could be broadly useful outside of error handling).

3 Likes

Well, no, the main reason we don't want embedded environments always using Result is that it's manual error propagation and has most of the disadvantages of that. Using precise error typing would be a necessary evil for their environment, but at least they'd be using the language's standard error propagation.

1 Like

I fully understand that this isn‘t a concrete design / syntactic pitch, however I personally can express my thoughts better with such hypothetical examples. I never intended to say that the name should be commonSuperError, it just signals a bit better what I actually meant by that.

I think rethrows has some pros and contras and if we‘re going to tackle precise error typing in Swift, we might also look into have we can improve rethrows as well.

Regarding the 'subtype join' functionality. Isn't that a part of the multi-statement type-checking?

I mean would the following non-throwing code compile in the future?

protocol P {}
class A: P {}
class A_1: A {}
class A_2: A {}

// TODAY:
// error: Unable to infer complex closure return type;
// add explicit type to disambiguate
// Future: infers as `() -> A` ???
let closure_1 = {
  if Bool.random() {
    return A_1()
  } else {
    return A_2()
  }
}

If so, it seems like the throwed error type should obey similar rules.

I think you're thinking of rethrows as a more independent feature from throws than I am. The predominant case of rethrows can be formalized trivially under precise error typing as

func && <E: Error>(lhs: Bool, rhs: @autoclosure () throws(E) -> Bool) throws(E) -> Bool

and I suspect we would encourage most uses of rethrows to switch over to that.

Ideally rethrows would simply become sugar for that, but alas, the current rethrows feature is semantically different in ways we're required to preserve. Given that, adding a precisely-typed rethrows as well as throws seems to just muddy things up on multiple levels, and I think it's better to just leave that spelling behind. I'm not sure it has much of a purpose in a world where you can just write the precisely-typed generic signature; it was always just an attempt to avoid needing a system capable of expressing that. We even tried to generalize it to generics, and that didn't really work out, and that was part of how I reached the conclusion we needed precise error typing.

7 Likes

No. Like normal function bodies and result builders, multi-statement closures will type-check statements sequentially, only allowing for forward propagation of type information between statements.

EDIT: Sorry, I hit send too soon :slightly_smiling_face:

The "no back propagation between statements" behavior is observable today with inference of the underlying type for opaque result types:

protocol P {}
class A: P {}
class A_1: A {}
class A_2: A {}

var inferResult: some P { // error: Function declares an opaque return type, but the return statements in its body do not have matching underlying types
  if Bool.random() {
    return A_1()
  } else {
    return A_2()
  }
}
1 Like

Oh I see. So basically if your function also throws some of its own errors you would instead use throws(E, Error) (borrowing your previous syntactic example) to indicate that even if the closure parameter does not throw, the actual function still may throw. Yeah I'm totally fine to move into such direction.


@hborla thank you for the clarification on that.

This would intentionally not be a union type. We would compute a supertype, and anything that fits in that supertype would be allowed. I continue to feel that anonymous sum types are over-complicated and promote evolutionary dead-ends; programmers who want unions should accept that they need an enum.

9 Likes

Right, although of course that signature in particular could be simplified to throws(Error), and thus to throws. But you could write throws(E, POSIXError), and then if E happens to be Never or POSIXError, you'd get a narrower error type.

2 Likes