Skip to content

Instantly share code, notes, and snippets.

@Anton3
Last active February 18, 2017 13:21
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Anton3/3e4081da1adbc6e7a7f377987985c289 to your computer and use it in GitHub Desktop.
Save Anton3/3e4081da1adbc6e7a7f377987985c289 to your computer and use it in GitHub Desktop.
Draft of "Typed throws" proposal

Allow Type Annotation on Throws

Introduction

Typed throws annotation specifies that a function can only throw errors of a certain type:

enum MyError : Error {  }

func foo() throws(MyError) {  }

do {
  try foo()
}
catch e {  }  // e : MyError

Motivation

The error handling system within Swift today creates an implicitly loose contract on the API. While this can be desirable in some cases, it’s certainly not desired in all cases. Consider usage of an API with error handling:

enum JSONError : Error {  }

/// Parse JSON from a string
/// - parameter from: The string to parse
/// - Throws: JSONError in case of invalid string format
/// - Returns: An object representing valid JSON
func parseJSON(from: String) throws -> JSON

do {
  let json = try parseJSON(from: s)
}
catch e as JSONError {  }
catch { }  // ← required

Because thrown errors cannot be stated in function declaration, compiler will give us a generic Error in catch clauses. Thus, a catch-all clause is mandatory for all throwing function calls. Because we know that we shouldn't ever hit catch-all, it's better to state our intent using fatalError:

do {  }
catch e as JSONError {  }
catch { fatalError() }

If the API changes error being thrown from JSONError to ParsingError, the application will crash. Compiler won't give us any warning in this case. In the end, despite many APIs taking on a strict approach to errors, with error types stated in documentation, all code dealing with error handling is unsafe in the described way.

Proposed solution

Add an optional type annotation to throws clause of function declarations and function types:

enum JSONError : Error {  }

func parseJSON(from: String) throws(JSONError) -> JSON

do {
  let json = try parseJSON(from: s)
}
catch e as JSONError {  }          // exhaustive; `as JSONError` is extra
// catch e { … }                    // alternative #1: e is-a JSONError
// catch { … }                      // alternative #2: error is-a JSONError
// catch JSONError.integerOverflow  // alternative #3: match on JSONError

Only one error type can be specified, be it an enum, a struct, or a protocol existential. This type must conform to Error protocol. Plain throws will still be allowed, and will be equivalent to throws(Error).

Generic throws type annotations

Generic error types must at least conform to Error protocol:

func exec<E>(f: () throws(E) -> Void) throws(E)
          where E: Error  // required
{ try f() }

Type-annotated rethrows

func exec(f: () throws(MyError) -> Void) rethrows(MyError) {  }

Type, which a function "rethrows", must be a common supertype of all types, which its function parameters "throw". In the following case, Error1 : Error3 and Error2 : Error3 must be true:

func seq(f: () throws(Error1) -> Void, g: () throws(Error2) -> Void) rethrows(Error3) {  }

Example with generics:

protocol MyBaseError : Error {  }

func seq<E1, E2>(f: () throws(E1) -> Void, g: () throws(E2) -> Void) rethrows(MyBaseError)
        where E1: MyBaseError, E2: MyBaseError {  }

In many cases, there won't be a common base type for two or more errors. In these cases, the suggested solution is to resort to unannotated rethrows:

func seq<E1, E2>(f: () throws(E1) -> Void, g: () throws(E2) -> Void) rethrows
        where E1: Error, E2: Error {  }

Detailed design

Grammar

throws-annotationthrows

throws-annotationrethrows

throws-annotationthrows ( type )

throws-annotationrethrows ( type )

function-typeattributes opt function-type-argument-clause throws-annotation opt function-result

function-signatureparameter-clause throws-annotation opt function-result opt

protocol-initializer-declarationinitializer-head generic-parameter-clause opt parameter-clause throws-annotation opt generic-where-clause opt

initializer-declarationinitializer-head generic-parameter-clause opt parameter-clause throws-annotation opt generic-where-clause opt initializer-body

closure-throws-annotationthrows

closure-throws-annotationthrows ( type )

closure-signaturecapture-list opt closure-parameter-clause closure-throws-annotation opt function-result opt in

Type conversions

A function type with throws is subtype of the same function type, but with a wider throws clause. Examples:

protocol A : Error {  }
protocol B : A {}
protocol C : B {}

typealias F<E: Error> = () throws(E) -> Void

var x: F<B> = 
var y = x as F<A>      // ok, because B: A is true
var z = x as F<C>      // error, because B: C is false
var w = x as F<Error>  // always ok

Also it’s possible to override functions with the throws annotations. Covariance on throws type is allowed.

Multiple throwing calls in one do block

If multiple throwing calls are in a single do block, then catch clauses must cover the set of error types. Otherwise, "unexhaustive" compilation error is generated. Example:

extension Error1 : BaseError {  }
extension Error2 : BaseError {}

func foo() throws(Error1)
func bar() throws(Error2)
func buz() throws(Error3)

do {
  foo(); bar(); buz()
}
catch e as BaseError {  }
catch e as Error3 {  }

Source compatibility

This is a non-breaking change. In a future proposal, we might want to improve the standard library in a non-breaking way, adding some throws annotations.

Effect on ABI stability

This feature won't touch ABI. It will be a part of type checking and will only exist at compilation stage.

Effect on API resilience

This feature can be removed later without touching ABI.

APIs can only evolve by adding more narrow throws annotations. Widening error types or removing type annotations from throws will break code of clients of those APIs.

Alternatives considered

Syntax versions

Proposed version (for comparison)

Improves incrementally on current throws syntax. Parametrized annotations are already used in other parts of the language.

() throws(Error) -> Result

No parentheses

This version feels lighter. The major disadvantage is visual ambiguity: does the function throw Error -> Result type?

() throws Error -> Result

throws at the end

This version mimics Either type from other languages.

() -> Result throws Error

It gets tricky with curried functions:

() -> (() -> Result) throws Error

Type unions

In this version, the concept of throwing is based on type unions, or implicit enums A | B.

() -> Result | Error

This may seem nice and minimalistic from theoretical point of view, but on practise | does not mean "error handling" at all. Additionally, type unions have been explicitly rejected.

Future work

Current proposal is intended to be minimalistic and keep controversal features out as much as possible. Typed throws can be extended in a series of future proposals.

Allowing multiple error types to be thrown

func getPreferences() throws(FileNotFoundError, ParseError) -> Preferences {  }

Some say that this change would bring in Java-madness, where lists of passing-through errors grow very fast and become meaningless.

Removing Error protocol

Error protocol will be replaced with Any, because making all errors conform to an marker protocol seems redundant.

This will be a breaking change, so it was excluded from current proposal.

Removing plain throws

Because throwsthrows(Error) or throws(Any), plain throws seems redundant.

This will be a breaking change, so it was excluded from current proposal.

Making non-throwing functions equivalent to throwing Never

In generic context, this will allow to replace rethrows to some extent:

func exec<E>(f: () throws(E) -> Void) throws(E) {  }

Here, if E = Never, then we get non-throwing version of exec. Therefore, we can remove rethrows from the language.

Criticisms

From the earlier threads on the swift-evolution mailing list, there are a few primary points of contention about this proposal.

Aren’t we just creating Java checked-exceptions, which we all know are terrible?

No. The primary reason is that a function can only return a single error-type. This already greatly reduces the deep class-based, exception-type model to a single, polymorphic error type (for class-based ErrorType implementations). Swift also takes a different model than Java; this was mostly laid out here: Error Handling Rationale. But briefly, many of the numerous exceptions that are thrown in Java are of the "Universal Error" classification, which Swift's error model doesn't handle.

Aren’t we creating fragile APIs that can cause breaking changes?

Potentially, yes. This depends on how the ABI is handled in Swift 3 for enums. The same problem exists today, although at a lesser extent, for any API that returns an enum today.

Chris Lattner mentioned this on the thread:

The resilience model addresses how the public API from a module can evolve without breaking clients (either at the source level or ABI level). Notably, we want the ability to be able to add enum cases to something by default, but also to allow API authors to opt into more performance/strictness by saying that a public enum is “fragile” or “closed for evolution”.

So if enums have an attribute that allows API authors to denote the fragility enums, then this can be handled via that route.

Another potential fix is that only internal and private scoped functions are allowed to use the exhaustive-style catch-clauses. For all public APIs, they would still need the catch-all clauses.

For APIs that return non-enum based ErrorType implementations, then no, this does not contribute to the fragility problem.

Aren’t we creating the need for wrapper errors?

This is a philosophical debate. I’ll simply state that I believe that simply re-throwing an error, say some type of IO error, from your API that is not an IO-based API is design flaw: you are exposing implementation details to users. This creates a fragile API surface.

Also, since the type annotation is opt-in, I feel like this is a really minor argument. If your function is really able to throw errors from various different API calls, then just stick with the default ErrorType.

However, it is the case that if you do wish to propogate the errors out, then yes, you need to create wrappers. The Rust language does this today as well.

Why are multiple error types not allowed to be specified?

To clear, it's not because of “Java checked exceptions” (as it might be inferred because of the defense to Java's checked exceptions). Rather, it’s because nowhere else in the language are types allowed to be essentially annotated in a sum-like fashion. We can’t directly say a function returns an Int or a String. We can’t say a parameter can take an Int or a Double. Similarly, I propose we can’t say a function can return an error A or B.

Thus, the primary reason is about type-system consistency.

Swift already supports a construct to create sum types: associated enums. What it doesn’t allow is the ability to create them in a syntactic shorthand. In this way, my error proposal does the same thing as Rust: multiple return types need to be combined into a single-type - enum.

If Swift is updated to allow the creation of sum-types and use them as qualifiers for type declarations, then I don't see how they wouldn't simply fall inline here as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment