[swift-evolution] Proposal: Allow Type Annotations on Throws

Matthew Johnson matthew at anandabits.com
Mon Dec 21 08:36:56 CST 2015


Hi David,

I spent a lot of time last night thinking about how my concerns can be addressed without changes to the type system.  I also spent some time working through some concrete examples this morning.  This has helped narrow my concerns considerably.

I am going to suggest one addition to the proposal at the end.  If you’re willing to incorporate that I will be pretty happy with what we can accomplish without any changes to the type system.

First, consider the case where there are some common errors which a library may throw in different places.  These are considered to be part of the API contract.  Some library functions may throw either common error depending on the code path taken.  

Your proposal suggests we should fall back to throwing ErrorType in that case.  This is not really a good solution in my mind.  

A library should be able to have a family of error types it publishes in its API contract and some functions should be able to throw more than one.  As you suggest, rather than a structural sum type we can manually create a sum type to do this.  

I had two concerns about this.  The first and most important was in the verbosity of catching the nested errors.  Here’s an example:

enum CommonOne: ErrorType {
    case One
    case Two
}
enum CommonTwo:ErrorType {
    case One
    case Two
}
enum Both: ErrorType {
    case One(CommonOne)
    case Two(CommonTwo)
}

I was concerned that we would need to do something like this involving some verbose and nasty nesting, etc:

func functionThatThrowsBoth() throws Both { … }

do {
    try functionThatThrowsBoth()
}
catch .One(let inner) {
    switch inner {
        case .one: ...
        case .two: ...
    }
}
catch .Two(let inner) {
    switch inner {
        case .one: ...
        case .two: ...
    }
}

As it turns out, I am still getting familiar with the power of nested pattern matching and this was an unfounded concern.  This is great!  We can actually do this:

do {
    try functionThatThrowsBoth()
}
catch .One(.One) { ... }
catch .One(.Two) { ... }
catch .Two(.One) { ... }
catch .Two(.Two) { ... }

(note: this works today if you include a Both prefix in the cases which will be unnecessary with a typed error)

That is great!  I have no concerns about this syntax for catching nested errors.  This covers use cases that need to throw “multiple” error types pretty well.  There are probably some edge cases where a structural sum type would be more convenient but I think they would be rare and am not concerned about them.

I would also like to comment that there are some interesting related ideas for enhancing enums in the "[Pitch] Use enums as enum underlying types” thread.  They don’t directly impact the proposal but could make such use cases even more convenient if they are pursued independently.

The other concern I have is still valid, but I think a relatively straightforward solution is possible.

Continuing with the previous example, let’s look at the implementation of `functionThatThrowsBoth`:

func throwsInnerOne() throws InnerOne {
    throw InnerOne.One
}

func throwsInnerTwo() throws InnerTwo {
    throw InnerTwo.Two
}

func functionThatThrowsBoth(_ whichError: Bool) throws Both {
    do {
        if whichError {
            try throwsInnerOne()
        } else {
            try throwsInnerTwo()
        }
    }
    catch let inner as InnerOne { throw Both.One(inner) }
    catch let inner as InnerTwo { throw Both.Two(inner) }
}

The implementation is dominated by the concern of wrapping the error.  This is pretty gross.  This problem exists even if we are not wrapping the error, but rather translating the error from the underlying error type into the error type we are publishing in our API contract.  

Here is an example where we are not wrapping the error, but translating it:

func functionThatCallsUnderlingyingThrows(_ whichError: Bool) throws MyPublishedError {
    do {
        try funcThatThrowsAnErrorThatMustBeTranslatedIntoMyPublishedError()
    }
    // catching logic that eventually throws MyPublishedErrorSomehow
}

The best we can do is to create a translation function or initializer:

enum MyPublishedError: ErrorType {
    init(_ error: UnderlyingError) { … }
}

func functionThatCallsUnderlingyingThrows(_ whichError: Bool) throws MyPublishedError {
    do {
        try funcThatThrowsAnErrorThatMustBeTranslatedIntoMyPublishedError()
    }
    catch let error as UnderlyingError { throw MyPublishedError(error) }
}

This is better as it removes the logic from the function itself.  But it’s still not great as it introduces a lot of boilerplate everywhere we need to translate and / or wrap errors.  The boilerplate also grows for each underlying error we need to translate:

func functionThatCallsUnderlingyingThrows(_ whichError: Bool) throws MyPublishedError {
    do {
        // bunch of stuff throwing several different errors
    }
    catch let error as UnderlyingError { throw MyPublishedError(error) }
    catch let error as OtherUnderlyingError { throw MyPublishedError(error) }
    // more catch clauses until we have covered every possible error type thrown by the body
    // hopefully the compiler wouldn’t require a default clause here but it probably would
}

This is the problem that `From` addresses in Rust.  Swift is not Rust and our solution will look different.  The point is that this is a problem and it can and has been solved.

My suggestion is that we should allow implicit conversion during error propagation.  If the published error type has one and only one non-failable, non-throwing initializer that takes a single argument of the type that is thrown (including enum case initializers with a single associated value of the thrown type) that initializer is used to implicitly convert to the published error type.  This conversion could be accomplished by synthesizing the necessary boilerplate or by some other means.

Now we have:

func functionThatCallsUnderlingyingThrows(_ whichError: Bool) throws MyPublishedError {
        try funcThatThrowsAnErrorThatMustBeTranslatedIntoMyPublishedError()
}

This looks as it should.  We don’t pay a price of boilerplate for carefully designing the errors we expose in our API contract.  This also handles automatic wrapping of errors where that is appropriate.

I don’t suggest implicit conversion lightly.  I generally hate implicit conversions.  But I think it makes a lot of sense here.  It keeps concerns separate and removes boilerplate that distracts from the logic at hand, thus vastly improving readability.  It is also likely to help minimize code impact when implementation details change and we need to modify how we are translating errors into the contract we expose.

If we don’t support the implicit conversion there are three paths that can be taken by developers.  None of them are great and we will have three camps with different preference:

1. Just stick to untyped errors.  I think there are some pretty smart people who think this will be a common practice even if we have support for typed errors in the language.
2. Allow underlying errors to flow through (when there is only a single underlying error type).  This is brittle and I know you are of the opinion that it is a bad idea.  I agree.
3. Write the boilerplate manually.  This is annoying and is a significant barrier to clarity and readability.

I hope you will like the idea of implicit conversion during error propagation enough to add it to your proposal.  With it I will be an enthusiastic supporter.  It will help to establish good practices in the community for using typed errors in a robust and thoughtful way.

Without implicit error conversion I will still support the proposal but would plan to write a follow on proposal introducing the much needed (IMO) implicit conversion during error propagation.  I would also expect opposition to the proposal during review from people concerned about one or more of the above listed options for dealing with error translation.

I think the idea of restricting typed errors to structs, enums, NSError, and final classes that has come up is a good one.  It might be worth considering going further than that and restrict it to only enums and NSError.  One of the biggest issues I have encountered with error handling during my career is that all too often the possible error cases are quite poorly documented.  We have to allow NSError for Cocoa interop, but aside from the error types should really be enums IMO as they make it very clear what cases might need to be handled.

I want to thank you again for putting this proposal together and taking the time to consider and respond to feedback.  Typed errors will be a significant step forward for Swift and I am looking forward to it.  

Matthew





> On Dec 18, 2015, at 12:36 PM, David Owens II <david at owensd.io> wrote:
> 
> 
>> On Dec 18, 2015, at 9:41 AM, Matthew Johnson <matthew at anandabits.com <mailto:matthew at anandabits.com>> wrote:
>> 
>> I’m not asking for you to speak for them.  But I do think we need to learn from communities that are having success with typed error handling.  Your proposal would be stronger if it went into detail about how it would avoid the problems that have been encountered in other languages.  The experience of Rust could help to make that case as it is concrete and not hypothetical.
> 
> Sure, it could. It’s also anecdotal. It’s not necessarily true that something that works well in one context works well in another. It’s good to note that typed errors are wholly considered bad, but I’m not sure how much further we need to go then that. If you have specifics, then I could probably add them as an addendum to the proposal.
> 
>> My understanding is that Rust uses static multi-dispatch to do this.  I don’t believe it has anything to do with structural sum types.  Rust error handling uses a Result type with a single error case: http://doc.rust-lang.org/book/error-handling.html <http://doc.rust-lang.org/book/error-handling.html>.
> 
> That example takes you through many of the options available. In the end, you end up at the sum-type for the error:
> fn search<P: AsRef<Path>>
>          (file_path: &Option<P>, city: &str)
>          -> Result<Vec<PopulationCount>, CliError> {
>     ...
> }
> It’s the CliError which is defined as:
> enum CliError {
>     Io(io::Error),
>     Csv(csv::Error),
>     NotFound,
> }
> The From() function essentially allows the try! macro to expand these in a nicer way.
> 
> So back to the proposal, one of the key things is to promote the `error` constant throughout the catch-clauses. This means that we can already leverage Swift’s pattern matching to solve this problem:
> 
> enum Combined {
>     case IO(String)
>     case Number(Int)
> }
> 
> func simulate(err: Combined) {
>     switch err {
>     case let Combined.IO(string) where string == "hi": print("only hi!")
>     case let Combined.IO(string): print(string)
>     case let Combined.Number(value): print(value)
>     }
> }
> 
> simulate(Combined.IO("hi"))
> simulate(Combined.IO("io"))
> simulate(Combined.Number(9))
> 
> It’s not hard to use Swift’s pattern matching to extract out the inner information on an associated value enum and white the case/catch clauses. So unless I’m missing something, I think Swift already provides a good mechanism to do what you’re asking for, with the caveat that the `error` constant is promoted to be usable in the catch-clauses similar to how the switch-statements work.
> 
> Maybe adding this to the proposal would clarify usage?
> 
>> How does this create a fragile API surface area?  Adding a new error type to the signature would be a breaking change to the API contract.  This is really no different than changing the type of error that can be thrown under your proposal.
> 
> It’s the same fragility that enums create; this was covered in the criticisms section. The likelihood of adding additional error cases is much greater than a change that would completely change the type of the error.
> 
>> 
>>> I see this functionality as a general limitation in the language. For example, errors are not the only context where you may want to return a type of A, B, or C. There have been other proposals on how we might do that in Swift. If and when it was solved in the general case for type parameters, I can’t foresee a compelling reason why it wouldn’t work in this context as well.
>> 
>> That makes sense in some ways, but I don’t think it’s unreasonable to ask for some analysis of whether a better design for typed errors would be possible if we had them.  IMO it’s pretty important to get the design of typed errors right if / when we add them.  If we don’t it will be considered a major mistake and will lead to a lot of less than desirable outcomes down the road.
>> 
>> I also think typed errors may be one of the more important use cases for structural sum types of some kind.  If we are able to show that design problems that cannot be solved without them can be solved with them that might influence whether they are added or not.  It might also influence when it makes sense to add support for typed errors to the language.
> 
> The problem can be solved without implicitly generated sum types though. The design of typed errors, as proposed, is to be consistent with the Swift type system today. Regardless, I’ve added a response in the “cirticisms” section that hopefully addresses this in some manner - basically, yes it would be helpful, but out of scope for this proposal.
> 
>> That approach would make catch clauses rather clunky by nesting errors inside of associated values.  If you’re advocating for this approach do you have any ideas on how to streamline syntax for catching them?
> 
> See above example. Does that address this concern?
> 
> -David
> 

-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://lists.swift.org/pipermail/swift-evolution/attachments/20151221/5aea3449/attachment.html>


More information about the swift-evolution mailing list