Status check: Typed throws

That's fantastic to hear. I only regret that I didn't also include in my original post a status check on that unicorn I've long wanted, or on the whereabouts of my winning lottery ticket. :laughing:

Future directions

I think it wise that an initial implementation allow only a single error type to be specified. That addresses important specific use-cases (e.g. embedded) and can be the basis for real-world experience with which to inform future directions.

Still, I do want to think a little ahead on future directions, in order to ensure no doors are unwittingly closed. This might be a non-issue, in which case the following is not yet pertinent - I don't know nearly enough about the Swift compiler to even guess.

@John_McCall's post does a good job laying out the cons of supporting multiple error types, and why a degree of scepticism is healthy. Still, for the same reason precise error typing is warranted even though it might only be appropriate a minority of the time, I think being able to specify multiple errors types and values has its place too.

In a nutshell, I think it's very attractive if thrown errors function much like enums - the caller can see what the possibilities are, the compiler can verify completeness (all enum cases handled or a catch-all provided), etc.

But you can already make error enums, today, right?! Yes, but the problem is that composition is essential & frequent yet completely manual today. Most throwing functions call other throwing functions - often multiple with potentially different error types. So you either end up trying to shove every error condition into one giant enum (in the process, losing any indication of which actual error conditions a specific function can encounter) or you waste a lot of time translating between or making wrappers over error types. Either way your code becomes more laborious and error-prone to use & maintain, more verbose, and less flexible.

It'd be great if the compiler just did that for you, by implicitly creating those composite enums.

So ideally it would be possible to say:

  • throws without an explicit type, equivalent to throws(any Error).

    i.e. what we have today. So, boxed with intrinsic runtime overhead, but completely flexible and often the best choice if the caller's really only likely to log the error description anyway.

  • throws(someType) means only errors of that type.

    Has performance benefits at runtime (no boxing etc, at least no more than the type itself imposes such as if it is a reference type).

    A good option for fairly trivial or unlikely-to-change resilient APIs, especially since you can potentially still extend the error type itself to handle new cases (e.g. add additional enum values).

  • throws(someErrorType, ...) (or equivalently but IMO less ideally throws(someErrorType, any Error)).

    Technically means the type is any Error - with all the existing runtime overhead - but it provides a hint to the reader (human or otherwise, e.g. LSP) that someType errors can definitely be thrown and would be wise to prioritise in e.g. auto-completion of catch statements. Even while preserving the ability for the type(s) of thrown errors to be changed arbitrarily over time.

    A good option for resilient APIs with decidedly non-trivial functionality or implementations, where you don't want to limit yourself to even the vague class(es) of errors possible.

    Nominally this can be done today with structured documentation (/// throws: etc), and the LSP et al could use that equivalently for code completion etc. But structured documentation isn't actually syntax-checked let-alone type-checked, and is more likely to be missing or wrong (e.g. outdated).

  • throws(someErrorType, someOtherErrorType) means errors can be of either type but not any other type.

    This is essentially an implicit, anonymous enum of the specified types. It might have runtime benefits (no need for boxing if all the encompassed types are value types?) although still has to be interrogated at runtime to see which of the component types the error actually is. In any case, it does provide clarity to readers and it allows the compiler to check both:

    • That all the listed types are actually thrown in the function's implementation (whether directly or from children).

      It should at least warn, if not error, if a type is listed as thrown but never actually thrown (though some override might be necessary for this diagnostic, as there might be valid cases for this - e.g. a resilient API that no longer throws some types of errors but cannot remove the declaration because that would be backwards-incompatible?).

    • That the caller handles the complete set.

      This allows the caller to omit the "default" catch clause since the compiler knows the finite set of possibilities, a la an enum (although just like any enum, the caller is free to use a "default" catch clause anyway if it suits them better than explicitly enumerating all the cases).

      This then gets all the benefits we love from enums, such as if a new version of the function changes which errors it declares it can throw, the compiler can let all the callers know that they need to be updated and in precisely what way.

It gets even more powerful when you allow not just error types but values, e.g.:

  • throws(someErrorEnum.badInput, someOtherErrorEnum.networkError)

    Implicitly creates an anonymous, bespoke error enum that contains just those two cases, allowing named error enums to be better designed. e.g. logical categories like "NetworkError" and "JSONParserError" rather than a big fat catch-all "MyModuleError".

    This can be more efficient (than any Error) at runtime since it's technically equivalent to defining a new error enum and specifying that as the concrete, sole thrown type.

  • throws(someErrorEnum.badInput, NetworkError) (i.e. a mix of types and values)

    Likely a very common pattern where NetworkError encompasses what the lower-level functions can throw, and badInput is what the function in question can itself throw. Allows practical use of typed errors much further up the stack since the function can choose to apply abstraction for simplicity and forward-compatibility, e.g.:

    func search(query: String) throws(SearchError.emptyQuery,
                                      NetworkError) {
        guard !query.isEmpty else {
            throw SearchError.emptyQuery
        }
    
        try connection.performSearch(query: query)
    }
    
    // Elsewhere, in some Connection class:
    func performSearch(query: String) throws(NetworkError.timeout)
    

    Technically search(query:) only throws a subset of possible NetworkErrors - just the .timeout that performSearch(query:) throws - but it might be deemed (architecturally) that callers of the higher-level API don't care enough what specifically went wrong with the network, and/or that it's just wise for future-proofing to not make too many assumptions about the types of network errors that can crop up while performing a search, or that it's simply a better trade-off for code flexibility vs tightly coupling the declarations of the two methods (similar trade-off as for enums w.r.t. using a default switch case or listing every case explicitly).

    Nonetheless callers of search(query:) still know that the only possible errors are if the query is empty or some network error occurs. So they can tailor their responses much better than if it were any Error - e.g. catch SearchError.emptyQuery specifically and provide a crystal-clear bit of feedback to the user, and catch NetworkError more generically and just retry or somesuch.

2 Likes

In such cases, you should use untyped throws.

and

are what I'd go for. Also, throws(Never) is equivalent to non-throwing.

From here on, I disagree with the direction you're going. A lot of the motivations and benefits for typed throws taper off once you try to get more specific than "throw this type", because you start creating implicit unions of types and possibly values, you lose the direct relationship with Result and throwing, and you end up getting the kind of junk-drawer-of-possibly-thrown-types that plague (e.g.) Java's checked exceptions. Typed throws with a single specified thrown type hits that happy middle.

Doug

20 Likes

Well, we'll see. :slightly_smiling_face:

1 Like

One suggested improvement I've heard for typed throws in languages like Java is

func foo() throws MyError {
  ...
}

func bar() throws like foo {
  try foo()
}

That way, changing the throw type(s) of foo doesn't require changes everywhere.

1 Like

Composition of type union types and typed throws with exactly one type would be more scalable than trying to fit partial support for multiple types in typed throws. (IMO)

func f() throws(Error1 âˆȘ Error2)
func g() -> Result<Void, Error1 âˆȘ Error2>

I don't like like part of it. And in general I believe composition of two distinct features is better than some ad-hoc syntax. In particular what you're suggesting could be expressed as

func bar() throws(foo.Throws)

where foo.Throws is a type of throws of foo.
Designing it this way reduces the complexity of the Typed Throws feature and gives room for careful thought about how exactly throws' type inference from function reference should work.

I didn’t mean to suggest that exact syntax was how it had to be done, just that the concept should exist. I don’t want the capability to get lost in the syntax.

For sure. It's just that full-blown support for such union types is a much bigger undertaking. That and a bunch of other potential enhancements (e.g. conditional throws, rethrows of more than just immediate parameters, etc) are things I'd love to see too. Again, my emphasis in bringing up support for multiple types is just to ensure it's not inadvertently ruled out in the initial implementation. We won't know if it's worth it - or necessarily how best to implement it, e.g. full union type support vs something more specialised - until we get real-world experience with typed throws to begin with.

1 Like

Something I'd like to add to this discussion is that a possible alternative to enums and union types is protocols, which would discourage people from trying to exhaustively handle each error case, and instead nudge them towards handling the errors using some common functionality that the errors have. This can be made more powerful with "blanket" (protocol-to-protocol) conformances like extension SomeErrorProtocol: MyErrorProtocol. For example, there can be a protocol for errors that can display a localized error message or graphical representation for a specific application, while existing types like CodingError or DistributedActorSystemError can be retroactively conformed to it as necessary.

I also wonder if it would be helpful to think of two "kinds" of error handling:

  1. General errors, which can potentially come from anywhere in the application. These are normally boxed in existentials and passed around in general-purpose error handling code. Callers should just let them propagate up the stack in most situations, since exhaustively handling each possible case would be impractical.
  2. Domain-specific errors, which warrant explicit handling of each possible case close to the call site. These don't necessary have to conform to Error, and not conforming to Error would prevent them from accidentally "escaping" into general-purpose error handling code if they would not be suitable for it.
    • Using non-Error-conforming enums for domain-specific errors would also prevent a certain class of bugs, where you accidentally handle errors that come from deeper in the call stack than you intended. This occasionally comes up in languages like Python, where exceptions like KeyError (from __getitem__), StopIteration (from __next__), and ValueError (when converting strings to other types like int or Decimal) usually signal a special condition to be handled by the caller, but catching them can potentially mask errors deeper in the call stack.

Building on one of @wadetregaskis's examples:

func search(query: String) throws SearchError {
    ...
}

// Does *not* conform to `Error`, since `.emptyQuery`
// should be handled as close to the call site as possible,
// and `.other` needs to be unwrapped from the enum before
// being propagated to general-purpose error handling code.
enum SearchError {
    case emptyQuery
    case other(any Error)
}

do {
    try search(query: "query")
} catch .emptyQuery {
    // domain-specific error handling...
} catch .other(let error) {
    throw error
}
2 Likes

I'll be glad on helping on whatever is needed for this great proposal!

1 Like

For me, it does not feel like the “happy middle” but like “gave up before it was done”.

If there is only one type allowed, I will end up writing boiler plate code to throw an enum for what could have been a list of types in the first place. (Today, it is multiple as? XXXError)

It’s an exercise for macro coding: Build a macro @throws(ClassA, ClassB, 
) for funcs that generates that said enum #functionName_Error and adds the throws #functionName_Error to the func declaration.

2 Likes

(Sorry if this has already been brought up, I did not read every message of this thread)

Very excited to hear that you started implementation of Typed Errors :heart_eyes: for me this is a Swift improvement almost as exciting as macros!

What are your thoughts on (or already working code for) handling "aggregation"/"mapping" of typed errors?

I think this will be a super common pattern:

public enum NetworkError: Swift.Error, Sendable, Equatable {
	case badHTTPResponseCode(Int)
}
public enum JSONError: Swift.Error, Sendable, Equatable {
	// naive implementation `DecodingError` made equatable: https://gist.github.com/Sajjon/be1e0204930cef6549e9069094ad577a
	case decodingError(DecodingError)
}

public enum MyError: Swift.Error, Sendable, Equatable {
	case network(NetworkError)
	case json(JSONError)
}

public func makeNetworkRequest(path: String) async throws(NetworkError) -> Data { ... }
public func decode<T: Decodable>(data: Data, as: T.Type = T.self) throws(JSONError) -> T { ... }

public struct User: Decodable, Sendable, Equatable { ... }

And then combine makeNetworkRequest and decode in a function which throws MyError, and then we would have to "manually" aggregate the two thrown error types into our MyError type, like so:

/// Uh... cumbersome boilerplaty mapping error types into MyError :/
public func fetchUser(...) async throws(MyError) -> User {
	do {
		let data = try await makeNetworkRequest(path: "user/..")
		return try decode(data: data, as: User.self)
	} catch let networkError as NetworkError {
		return .networkError(networkError)
	} catch let jsonError as JSONError {
		return .json(jsonError)
	}
}

(Here I assume we do not need a "catch-all" catch {} since the only two possible error types NetworkError and JSONError is accounted for.)

But what would be amazing, would be some kind of Swift compiler magic, which would allow me to just do:

public func fetchUser(...) async throws(MyError) -> User {
	let data = try await makeNetworkRequest(path: "user/..")
	return try decode(data: data, as: User.self)
}

I'm no compiler engineer, but it feels like this ought to be possible - if and only the MyError enum contained one single case with each of the thrown Error types. Meaning if I would add case foo(NetworkError) then such compiler magic would not be possible, since how to know which case the NetworkError would go into. And analogously if I were to change case networkError(NetworkError) into something silly like case networkError(NetworkError, url: String) then that would also make such compiler magic impossible.

Now, how badly do I want the two line version? Very very badly :D since just the increased indentation makes the code hard to read, and it is just doing a boilerplaty mapping. And for functions with many many different error types, such mappings really bloats the code base.

Surely you have considered this already? is it doable? :)

If such compiler magic is not possible, what about some new syntax allowing for shorthand mapping, something like:

public func fetchUser(...) async throws(MyError) -> User {
	let data = try await makeNetworkRequest(path: "user/..") case .network
	return try decode(data: data, as: User.self) case .json
}

or

public func fetchUser(...) async throws(MyError) -> User {
	let data = as .network try await makeNetworkRequest(path: "user/..")
	return as .json try decode(data: data, as: User.self) 
}

or:

public func fetchUser(...) async throws(MyError) -> User {
	let data = try await makeNetworkRequest(path: "user/..") as case .network
	return try decode(data: data, as: User.self) as case .json
}

or perhaps:

public func fetchUser(...) async throws(MyError) -> User {
	let data = try await makeNetworkRequest(path: "user/..") into .network
	return try decode(data: data, as: User.self) into .json
}

It’s great to see progress on typed throws! I have a few design points to consider that are not mentioned in the posts you linked.

Non-throwing error types

Both the proposal draft and the post from @John_McCall post seem to assume all non-throwing functions have error type of Never. This would be unfortunate. All functions with uninhabited error types should be considered non-throwing. This implies a tweak to John’s expression for computing a rethrown error type:

func foo<AError: Error, BError: Error>(a: () throws(AError) -> Int,
                                       b: () throws(BError) -> Int)
    throws(errorUnion(AError, BError) == Never ? Never : Error) -> Int

Becomes

func foo<AError: Error, BError: Error>(a: () throws(AError) -> Int,
                                       b: () throws(BError) -> Int)
    throws(errorUnion(AError, BError) == Never || AError is uninhabitable && BError is uninhabitable ? Never : Error) -> Int

This expression still leaves something to be desired: AError and BError may have a more specific common supertype than Error`. Ideally the compiler would be able to figure this out.

Typed rethrows

The fundamental semantic of rethrows is that a function can only throw when an argument actually throws. In the presence of typed errors, it should be possible to catch and transform errors before rethrowing. This implies it should be possible to write rethrows TransformedError regardless of the error types thrown by arguments. In such a function, the body would catch the errors thrown by its arguments and convert them to TransformedError.

The difference between throws SomeError and rethrows SomeError would be subtle but important. When writing rethrows SomeError it is only possible to throw if an argument first throws. The rethrows expression logic above would be modified slightly with the explicitly specified error type replacing Error (or the computed common supertype). Thus, rethrows SomeError may also be non-throwing depending on the arguments it receives.

Catch type inference

The proposal mentions that type inference for general catch clauses was omitted due to requiring breaking changes. Hopefully this could be included in Swift 6.

Opaque error types

It would be great if error types could be opaque just like return types. I didn’t see any mention of these in the proposal or John’s post and it seems like this feature interaction deserves consideration.

7 Likes

I am not a compiler engineer or language engineer, but would it be possible for the compiler to make the error types implicit?

I would like the implicit annotation to extend to unions of errors too, not just throws to mean any Error - I imagine some developers may want to spell it out in the function signature, but some may prefer the ability to adapt to new error types without updating all signatures in their call stack.

For example:

func throwsA() throws {
  throw A()
}

func throwsB() throws {
  throw B()
}

func throwsAorB() throws {
  if Bool.random() {
    throw A()
  } else {
    throw B()
  }
}

...

do {
  try throwsA()
} catch { // error: A

}

...

do {
  try throwsB()
} catch { // error: B

}

...

do {
  try throwsAorB()
} catch let error as A { // error: A

} catch let error as B { // error: B

} // exhaustive, no need to add `catch` to handle the "any" case
1 Like

Do you have a link handy to any discussions about why precise error handling is useful in Embedded Swift? I vaguely recall seeing one.

Can you explain why this is important? You have a lot of generic error types that become uninhabited for specific applications?

What's the difference between a non-throwing entity and one that throws Never? To me the latter opens up a great generalization where you can easily write generic algorithms including the error types.

1 Like

Untyped errors are existentials (any Error), which would not be supported in the most restricted embedded environments. If you want to have the ability to throw at all in these environments, it has to be typed.

8 Likes

Yikes, okay. In that case, I hope this functionality would never be made available for resilient libraries! I strongly believe such ABI boundaries should always use untyped throws.

1 Like

It's useful for Never not to be treated specially so that the property of being uninhabited can be composed through other types. You can imagine an adapter function that adds some annotation to an error that passes through it:

struct Decorated<T: Error> {
  var error: T
  var decoration: Decoration
}

func decorateError<T: Error>(in body: () throws(T) -> Void) throws(Decorated<T>) {
  do {
    try body()
  } catch {
    throw Decorated(error: error, decoration: Decoration(...)
  }
}

If the body closure doesn't throw, then the error type is Never, and Decorated<Never> is also uninhabited, so decorateError<Never> can't throw either.

8 Likes