Typed throws

Rules for throws and catch

throws

  1. Any function/method, (protocol) init method, closure or function type that is marked as throws can declare which type the function/method throws.
  2. At most one type of specific error can be used with a throws.
  3. The error must conform to Swift.Error by (transitive) conformation.
  4. An error thrown from the function's body has to be compatible with the thrown error of the function's signature.

catch

  1. Throwing inside the do block using throws or a function that throws is handled the same regarding errors.
  2. A general catch clause always infers the error as Swift.Error
  3. #openquestion In general needless catch clauses are marked with warnings (prefering more specific ones to keep if there is a conflict between clauses). But it should be discussed for which scenarios we can apply these, because it's not easy do decide this for non trivial catch clauses or error type hierarchies.

#openquestion Alternative to consider:

If all statements in the do block throw specific errors and there is a catch clause that does not match one of this errors, then a compiler error is generated.

Error scenarios considered

Assuming the functions and errors

func callCat() throws CatError -> Cat
func callKids() throws KidsError -> Kids

struct CatError {
    reason: String
}

struct KidsError {
    reason: String
}

Scenario 1: Specific thrown error, general catch clause

do {
    let cat = try callCat()
} catch {
    // error is inferred as `Swift.Error` to keep source compatibility
}

Scenario 2: Specific thrown error, specific catch clause

do {
    let cat = try callCat()
} catch let error as CatError { // ensure `CatError` even if the API changes in the future
    // error is `CatError`
    // so this would compile
    let reason = error.reason
}

No general catch clause needed. If there is one, compiler will show a warning (comparable to default in switch).

Scenario 3: Specific thrown error, multiple catch clauses for one enum

// Assuming an enum `CatError`
enum CatError: Error {
    case sleeps, sitsOnATree
}

do {
    let cat = try callCat()
} catch .sleeps { // Type inference makes the catch clause more dense
    // handle error
} catch .sitsOnATree {
    // handle error
}

Scenario 4: Multiple same specific thrown errors, specific catch clause

do {
    let cat = try callCat()
    throw CatError
} catch let error as CatError {
    // error is `CatError`
}
// this is exhaustive

Scenario 5: Multiple differing specific thrown errors, general catch clause

do {
    let kids = try callKids()
    let cat = try callCat()
} catch {
    // `error` is just the type erased `Swift.Error`
    // because we can't auto generate KidsError | CatError
    // and we want to keep source compatibility
}

Scenario 6: Multiple differing specific thrown errors, multiple specific catch clauses

do {
    let kids = try callKids()
    let cat = try callCat()
} catch let error as CatError {
    // `error` is `CatError`
} catch let error as KidsError {
    // `error` is `KidsError `
}

Scenario 7: Multiple specific thrown errors, specific and general catch clauses

do {
    let kids = try callKids()
    let cat = try callCat()
} catch let error as CatError {
    // `error` is `CatError`
} catch {
    // `error` is `Swift.Error `
}

Scenario 8: Unspecific thrown error

  • Current behaviour of Swift applies

rethrows

The adjustments to rethrows differ depending on how many different errors are thrown by the typed throws of the inner functions.

With no error being thrown by the inner functions rethrows also does not throw an error.

If there is one error of type E rethrows will also throw E.

func foo<E>(closure: () throws E -> Void) rethrows // throws E

In the example above there's no need to constraint E: Error, as any other kind of object that does not conform to Error will throw a compilation error, but it is handy to match the inner Error with the outer one. So the set of functions in the Standard Library (map, flatMap, compactMap etc.) that support rethrows, can be advanced to their error typed versions just by modifying the signature like

// current
func map<T>(_ transform: (Element) throws -> T) rethrows -> [T]

// updated to
func map<T, E>(_ transform: (Element) throws E -> T) rethrows -> [T]

If there are only multiple errors of the same type rethrows throws an error of the same type.

func foo<E>(f: () throws E -> Void, g: () throws E -> Void) rethrows // throws E

If there are multiple differing errors rethrows just throws Error.

func foo<E1, E2>(f: () throws E1 -> Void, g: () throws E2 -> Void) rethrows // throws Error

Because information loss will happen by falling back to Error this solution is far from ideal, because keeping type information on errors is the whole point of the proposal.

(Theoretical) Alternatives for rethrowing multiple differing errors:

  • infer the closest common base type (which seems to be hard, because as to commenters in the forum type relation information seems to be missing in the runtime, however information loss on thrown error types will happen too)
  • Not for discussion of this proposal: sum types like A | B which were discussed and rejected in the past (see swift-evolution/xxxx-union-type.md)
  • use some replica sum type enum like
enum ErrorUnion2<E1: Error, E2: Error>: Error {
    case first(E1)
    case second(E2)
}

func foo<E1, E2>(f: () throws E1 -> Void, g: () throws E2 -> Void) rethrows // throws ErrorUnion2<E, E2> -> Void

But for rethrows to work in this way, these enums need to be part of the standard library. A downside to mention is, that an ErrorUnion2 would not be apple to auto merge its cases into one, if the cases are of the same error type, where with sum types A | A === A.

Usage in Protocols

We can define typed throws functions in protocols with specific error types that are visible to the caller

private struct PrivateCatError: Error {}
public struct PublicCatError: Error {}

protocol CatFeeder {
    public func throwPrivateCatErrorOnly() throws -> CatStatus // compiles
    public func throwPrivateCatErrorExplicitly() throws PrivateCatError -> CatStatus // won't compile 
    public func throwPublicCatErrorExplicitly() throws PublicCatError -> CatStatus // compiles
}

Or we can use associatedtypes that (implicitly) conform to Swift.Error.

protocol CatFeeder {
    associatedtype CatError: Error // The explicit Error conformance can be omited if there's a consumer that defines the type as a thrown one.
    
    func feedCat() throws CatError -> CatStatus
}

Usage with generics

Typed throws can be used in combination with generic functions by making the error type generic.

func foo<E>(e: E) throws E

E would be constrained to Error, because it is used in throws.

Subtyping

Between functions

Having related errors and a non-throwing function

class BaseError: Error {}
class SubError: BaseError {}

let f1: () -> Void

Converting a non-throwing function to a throwing one is allowed

let f2: () throws SubError -> Void = f1

It's also allowed to assign a subtype of a thrown error, though the subtype information is erased and the error of f2 will be casted up.

let f3: () throws BaseError -> Void = f2

Erasing the specific error type is possible

let f4: () throws -> Void = f3

In general (assuming function parameters and return type are compatible):

  • () -> Void is subtype of () throws B -> Void
  • () throws B -> Void is subtype of () throws -> Void
  • () throws B -> Void is subtype of () throws A -> Void if B is subtype of A

#openquestion For the semantic analysis it was suggested that every function is interpreted as a throwing function leading to this equivalences

() -> Void === () throws Never -> Void
() throws -> Void === () throws Error -> Void

But it should be discussed if these equivalences should become part of the syntax.

Catching errors that are subtypes

Following the current behaviour of catch clauses the first clause that matches is chosen.

class BaseError: Error {}
class SpecificError: BaseError {}

func throwBase() throws {
    throw SpecificError()
}

do {
    try throwBase()
} catch let error as SpecificError {
    print("specific") // uses this clause
} catch let error as BaseError {
    print("base")
}

do {
    try throwBase()
} catch let error as BaseError {
    print("base") // uses this clause
} catch let error as SpecificError {
    print("specific")
}

Protocol refinements

Protocols should have the possibility to conform and refine other protocols containing throwing functions based on the subtype relationship of their functions. This way it would be possible to throw a more specialised error or don't throw an error at all.

Examples from Typed throw functions - #223 by gwendal.roue

protocol Throwing {
    func f() throws
}

protocol NotThrowing: Throwing {
    // A non-throwing refinement of the method
    // declared by the parent protocol.
    func f()
}
protocol ColoredError: Error { }
class BlueError: ColoredError { }
class DeepBlueError: BlueError { }

protocol ThrowingColoredError: Throwing {
    // Refinement
    func f() throws ColoredError
}

protocol ThrowingBlueError: ThrowingColoredError {
    // Refinement
    func f() throws BlueError
}

protocol ThrowingDeepBlueErrorError: ThrowingBlueError {
    // Refinement
    func f() throws DeepBlueError
}

Type inference for enums

A function that throws an enum based Error can avoid to explicitly declare the type, and just leave the case, as the type itself is declared in the function declaration signature.

enum Foo: Error { case bar, baz }

func fooThrower() throws Foo {
    guard someCondition else {
        throw .bar
    }

    guard someOtherCondition else {
        throw .baz
    }
}

Assuming it is the only thrown error type in the do block, an enum based Error can have its cases catched, with each case having a separate catch clause. When catching cases the type of the case can be omitted, as it is inferred from the throwing function.

do { try fooThrower() }
catch .bar { ... }
catch .baz { ... }

Converting between throws and Result

Having a typed throws it would be quite convenient to not being forced to explicitly convert between throws and Result. Semantically throws could be just another syntax for Result, which would make both of them more composable.

func getCatOrThrow() throws CatError -> Cat
func getCatResult() -> Result<Cat, CatError>

So it would be nice if we could

do {
    let cat1: Cat = try getCatOrThrow() // works as always
    let cat2: Cat = try getCatResult() // `try` will unwrap the `Result` by calling `Result.get()`
    let catResult1: Result<Cat, CatError> = getCatResult()
    let catResult2: Result<Cat, CatError> = getCatOrThrow() `throws` is interpreted as the corresponding `Result`
    let cat3: Cat = getCatOrThrow().get() // same as `try getCatOrThrow()`
} catch let error as CatError {
    ...
}

But from what we know, this was already discussed before and was rejected in favour of a performant throws implementation.

But at least we recommend updating Result's init(catching:) from

init(catching body: () throws -> Success)

to

init(catching body: () throws Failure -> Success)

and Result's get() from

func get() throws -> Success

to

func get() throws Failure -> Success

Library Evolution

There are many concerns about library evolution and compatibility.

Non @frozen enums

Our approach is quite similar to what happens with switch cases:

enum NonFrozenEnum: Error { case cold, warm, hot }

func wheathersLike() throws NonFrozenEnum -> Weather

try { wheathersLike() } 
catch .cold { ... }
catch .warm { ... }
catch .hot { ... } // warning: all cases were catched but NonFrozenEnum might have additional unknown values.
// So if the warning is resolved:
catch let error as NonFrozenEnum { ... }

So it maintains backwards compatibility emiting a warning instead of an error. An error could be generated if this proposal doesn't need to keep backwards compatible with previous Swift versions.

API Developer recommendations

Assuming a current API

struct DataLoaderError {}

protocol DataLoader {
    func load throws DataLoaderError -> Data
}

Here are some things to consider when developing an API with typed errors:

  • If you need to throw a new specific error, because you think your API user needs to know, that this specific error (e.g. FileSystemError) did happen, then it's a breaking change, because your API user may want to react to it in another way now.

Changing

struct DataLoaderError {
    let message: String
}

to

enum DataLoaderError {
    case loadError(LoadError)
    case fileSystemError(FileSystemError)
}
  • If you don't need to throw it, because you think your API user does not need to know this error, then you map it to the error that represents the FileSystemError (most of the time in a more abstract sense). In this example you would throw a DataLoaderError with another message.

  • If you think you don't know what will happen in the future and breaking changes should be avoided as much as possible, then just throw Swift.Error. But keep in mind that you are less explicit about what can happen and also take the possibility for the API user to rely on the existence of the error (by using the compiler) leading to issues mentioned in Motivation.

  • If you provide an extension point to your API like a protocol (e.g. a DataLoader like above) that can be used to customize the behaviour of your API, then try to omit forcing specific errors on the API user. Most of the time you as an extension point provider just want to know that something went wrong. If you need multiple cases of errors then keep the amount as small as possible and eventually do compatibility converting on the API developer side outside of the extension point implementation. "Only ask for what you need" applies here.

Autocompletion

Because we can't infer types in the general catch clause without breaking source compatibility, we are suggesting to use autocompletion while adding missing catch clauses. Only catch clauses that are missing from the current do statement are suggested (including the general catch clause).

Optional Enhancement: Reducing catch clause verbosity

#openquestion

Because we now need to explicitly catch specific errors in catch clauses a lot, a shorter form is being suggested.
Having to write catch let error as FooError seems a bit inconsistent with the rest of how catch works, as error is inferred to be a constant of type Error in the general catch clause without mentioning let error.

do { ... }
// Here we have to explicitly declare `error` as a constant.
catch let error as FooError { ... }
// Whereas here you have `error` for free.
catch { ... }

This inconsistency can induce confusion when writing down different specific and general catch clauses, having to declare error on your own in one case and omitting it in the other.

For this the grammar would need an update in catch-pattern in the following way:

catch-pattern -> type-identifier

Example:

do {
    ...
} catch DogError {
    // `error` is `DogError`
}

This comes in handy with class and struct error types, but most of all with enum types.

enum SomeErrors: Error {
    case foo, bar, baz
}

func theErrorMaker() throws SomeErrors

do {
    try theErrorMaker()
}
catch .foo { ... }
catch .bar { ... }
catch .baz { ... }

This behaviour and syntax in general resembles a lot how switch cases work because:

  1. The type can be ignored, as it is inferred.
  2. They must be exhaustive.

In scenarios where different types are involved, each one has the same treatment from the grammar side:


do {
    try throwsClass() // throws MyClass
    try throwsStruct() // throws MyStruct
    try throwsEnum() // throws MyEnum { case one, two }
} 
catch MyClass { ... }
catch MyStruct { ... }
catch .one { ... }
catch .two { ... } 

And where multiple enums are being caught, it would be only needed to specify the type of those cases that were repeated in every enum.

enum One: Error { case one, two, three }
enum Two: Error { case two, three, four }


do { ... }
catch .one { ... }
catch One.two { ... } // Disambiguate the type.
catch One.three { ... }
catch Two.two { ... }
catch Two.three { ... }
catch .four { ... }

These scenarios are uncommon but possible, also there's always room to catch One and handle each case in a switch statement.

This change in the expression is merely additive and has no impact on the current source.

Source compatibility

We decided to keep the inference behaviour of the general catch clause (error: Error) to keep source compatibility. But if breaking source compatibility is an option, we could change this

do {
    let cat = try callCat() // throws `CatError`
    throw CatError
} catch let error as CatError {
    // error is `CatError`
}
// this is exhaustive

to this

do {
    let cat = try callCat() // throws `CatError`
    throw CatError
} catch {
    // error is inferred as `CatError`
}
// this is exhaustive

There is a scenario, that potentially breaks source compatibility (original post: Typed throw functions - #178 by Jumhyn):

struct Foo: Error { ... }
struct Bar: Error { ... }
var throwers = [{ throw Foo() }] // Inferred as `Array<() throws -> ()>`, or `Array<() throws Foo -> ()>`?
throwers.append({ throw Bar() }) // Compiles today, error if we infer `throws Foo`

The combination of type inference and throw can lead to trouble, because with more specific error types supported for throwing statements, the inferred type needs to change to the more specific type. At least this would be the intuitive behaviour.

Another example would be updating Result's init(catching:) and get().

In general: All locations where an error will be inferred or updated to a more specific error can cause trouble with source compatibility.

Effect on ABI stability

swift/Mangling.rst at master ยท apple/swift

function-signature ::= params-type params-type throws? throws-type?

#openquestion Any insights are appreciated.

Effect on API resilience

#openquestion Any insights are appreciated.

Alternatives considered

See Motivation

16 Likes