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.
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 tothrows(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 ideallythrows(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) thatsomeType
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, andbadInput
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 possibleNetworkError
s - just the.timeout
thatperformSearch(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 adefault
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 wereany Error
- e.g. catchSearchError.emptyQuery
specifically and provide a crystal-clear bit of feedback to the user, and catchNetworkError
more generically and just retry or somesuch.