Typed throws
- Excerpt of the current discussion on the thread: https://github.com/minuscorp/swift-typed-throws/blob/master/Typed%20throws%20discussion.md
- The latest version of the draft can always be found in: https://github.com/minuscorp/swift-typed-throws/blob/master/SE-XXX%20Typed%20throws.md
Swift Compiler PR: [AST][Sema] Typed throws implementation by minuscorp · Pull Request #33653 · apple/swift · GitHub
- Proposal: SE-NNNN
- Authors: Jorge Revuelta (@minuscorp), Torsten Lehmann
- Review Manager: TBD
- Status: Proposed
Introduction
throws
in Swift is missing the possibility to use it with specific error types. On the contrary Result
and Future
support specific error types. This is inconsistent without reason. The proposal is about introducing the possibility to support specific error types with throws
.
Swift-evolution thread: Typed throw functions - Evolution / Discussion - Swift Forums
Motivation
Swift is known for being explicit about semantics and using types to communicate constraints that apply to specific structures and APIs. Some developers are not satisfied with the current state of throws
as it is not explicit about errors that are thrown. These leads to the following issues with throws
current behaviour.
Communicates less error information than Result
or Future
Assume you have this Error type
enum CatError: Error {
case sleeps
case sitsAtATree
}
Compare
func callCat() -> Result<Cat, CatError>
or
func callFutureCat() -> Future<Cat, CatError>
with
func callCatOrThrow() throws -> Cat
throws
communicates less information about why the cat is not about to come to you.
If you write your own module with multiple throwing functions, that depend on each other, it's even harder to track down, which error can be thrown from which function.
func callKids() throws -> Kids // maybe you still know that this can only by a `KidsError`
func callSpouse() throws -> Spouse // but a `Spouse` can fail in many different ways right?
func callCat() throws -> Cat // `CatError` I guess
func callFamily() throws -> Family {
let kids = try callKids()
let spouse = try callSpouse()
let cat = try callCat()
return Family(kids: kids, spouse: spouse, cat: cat)
}
As a user of callFamily() throws
it gets a lot harder to understand, which errors can be thrown. Even if reading the functions implementation would be possible (which sometimes is not), then you are usally forced to read the whole implementation, collecting all uses of try
and investigating the whole error flow dependencies of the sub program. Which almost nobody does, and the people that try often produce mistakes, because complexity quickly outgrows.
Inconsistent explicitness compared to Result
or Future
throws
it's not consistent in the order of explicitness in comparison to Result
or Future
, which makes it hard to convert between these types or compose them easily.
func callAndFeedCat1() -> Result<Cat, CatError> {
do {
return Result.success(try callCatOrThrow())
} catch {
// won't compile, because error type guarantee is missing in the first place
return Result.failure(error)
}
}
func callAndFeedCat2() -> Result<Cat, CatError> {
do {
return Result.success(try callCatOrThrow())
} catch let error as CatError {
// compiles
return Result.failure(error)
} catch {
// won't compile, because exhaustiveness can't be checked by the compiler
// so what should we return here?
return Result.failure(error)
}
}
Code is less self documenting
Do you at least once stopped at a throwing (or loosely error typed) function wanting to know, what it can throw? Here is a more complex example. Be aware that it's not about a throwing function, but the problem applies to throwing functions as well. The root issue is the loosely typed error.
urlSession(_:task:didCompleteWithError:) | Apple Developer Documentation
optional func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?)
The only errors your delegate receives through the error parameter are client-side errors, such as being unable to resolve the hostname or connect to the host.
Ok so we show a pop-up if such an error occurs like we would if we have a response error. Furthermore we want to provide task cancellation because it's a big file download.
Now the user cancels the file download and sees the error pop-up which is not what we wanted.
What went wrong? You hopefully read the documentation of cancel() | Apple Developer Documentation or you debug the process and are surprised of this unexpected NSURLErrorCancelled
error.
Now you know (at least) that you want to ignore this specific error. But which other errors are you not aware of?
Outdated API documentation
API documentation could and usually become outdated, because it's not checked by the compiler. Furthermore the - throws:
documentation does not provide linking to errors (Apple Developer Markup Formatting Reference) making it even harder to find the error types in questions.
Assume some scarce documentation (more thorough documentation is even more likely to get outdated).
/// - throws: CatError
func callCatOrThrow() throws -> Cat
Let's update the method to load this cat from the network:
/// - throws: CatError
func callCatOrThrow() throws -> Cat { // now throws NetworkError additionally
let catJSON = try loadCatJSON() // throws NetworkError
// ...
}
struct NetworkError: Error {}
And there you have it. No one will check this NetworkError
in specific catch clauses, even though it's not unlikely to have another error message for network issues.
Potential drift between thrown errors and catch clauses
The example from section "Outdated API documentation" shows the issue where new errors from an updated API are not recognized by the API user. It's also possible that catched errors are replaced or removed by an updated API. So we end up with outdated catch clauses:
/// throws CatError, NetworkError
func callCatOrThrow() throws -> Cat
gets updated to
/// throws CatError, DatabaseError
func callCatOrThrow() throws -> Cat
struct DatabaseError: Error {}
Now we have outdated catch clauses
do {
let cat = try callCatOrThrow()
} catch let error as CatError {
// CatError will be catched here
} catch let error as NetworkError {
// won't happen anymore
} catch {
// DatabaseError will be catched here
}
Result
is not the go to replacement for throws
in imperative languages
Using explicit errors with Result
has major implications for a code base. Because the exception handling mechanism ("goto catch") is not built into the language (like throws
), you need to do that on your own, mixing the exception handling mechanism with domain logic (same issue we had with manual memory management in Objective-C before ARC).
Approach 1: Chaining Results
If you use Result
in a functional (i.e. monadic) way, you need extensive use of map
, flatMap
and similar operators.
Example is taken from Question/Idea: Improving explicit error handling in Swift (with enum operations) - Using Swift - Swift Forums.
struct GenericError: Swift.Error {
let message: String
}
struct User {
let firstName: String
let lastName: String
}
func stringResultFromArray(_ array: [String], at index: Int, errorMessage: String) -> Result<String, GenericError> {
guard array.indices.contains(index) else { return Result.failure(GenericError(message: errorMessage)) }
return Result.success(array[index])
}
func userResultFromStrings(strings: [String]) -> Result<User, GenericError> {
return stringResultFromArray(strings, at: 0, errorMessage: "Missing first name")
.flatMap { firstName in
stringResultFromArray(strings, at: 1, errorMessage: "Missing last name")
.flatMap { lastName in
return Result.success(User(firstName: firstName, lastName: lastName))
}
}
}
That's the functional way of writing exceptions, but Swift does not provide enough functional constructs to handle that comfortably (compare with Haskell/do notation).
Approach 2: Unwrap/switch/wrap on every chaining/mapping point
We can also just unwrap every result by switching over it and wrapping the value or error into a result again.
func userResultFromStrings(strings: [String]) -> Result<User, GenericError> {
let firstNameResult = stringResultFromArray(strings, at: 0, errorMessage: "Missing first name")
switch firstNameResult {
case .success(let firstName):
let lastNameResult = stringResultFromArray(strings, at: 1, errorMessage: "Missing last name")
switch lastNameResult {
case .success(let lastName):
return Result.success(User(firstName: firstName, lastName: lastName))
case .failure(let genericError):
return Result.failure(genericError)
}
case .failure(let genericError):
return Result.failure(genericError)
}
}
This is even more awful then the first approach, because now we are writing the implementation of the flatMap
operator over an over again.
Patterns of Swift libraries
There are specific error types in typical Swift libraries like DecodingError, CryptoKitError or ArchiveError. But it's not visible without documentation, where these errors can emerge.
On the other hand error type erasure has it's place. If an extension point for an API should be provided, it is often to restrictive to expect specific errors to be thrown. Decodable
s init(from:) may be to restrictive with an explicit error type provided by the API.
Like it's layed out in ErrorHandlingRationale there is valid usage for optionals and throws and we propose even for a typed throws. It comes down to how explicit an API should be and this can vary substantially based on requirements.
Proposed solution
In general we want to add the possibility to use throws
with a single, specific error.
func callCat() throws CatError -> Cat
Here is how throws
with specific error would reduce the issues mentioned in "Motivation".
Communicates the same amount of error information like Result
or Future
Compare
func callCat() -> Result<Cat, CatError>
with
func callCat() throws CatError -> Cat
It now contains the same error information like Result
.
Consistent explicitness compared to Result
or Future
throws
is now consistent in the order of explicitness in comparison to Result
or Future
, which makes it easy to convert between these types.
func callCat() throws CatError -> Cat
func callAndFeedCat1() -> Result<Cat, CatError> {
do {
return Result.success(try callCat())
} catch let error as CatError {
// would compile now, because error is `CatError`
return Result.failure(error)
}
}
func callAndFeedCat2() -> Result<Cat, CatError> {
do {
return Result.success(try callCat())
} catch let error as CatError {
return Result.failure(error)
} catch {
// this catch clause would become obsolete because the catch is already exhaustive
}
}
Code is more self documenting
struct RequestCatError: Error {
case network, forbidden, notFound, internalServerError, unknown
}
func requestCat() throws RequestCatError -> Cat
It's now guaranteed which errors can happen.
Drift between thrown errors and catch clauses can be stopped
You have an API with
func callCat() throws CatError -> Cat
and you make sure that you are aware of a possible CatError
by explicitly catching it
do {
let cat = try callCat()
} catch let error as CatError {
// CatError will be catched here
}
Now the API gets updated to
func callCat() throws TurtleError -> Cat
which would result in non compiling code on your side
do {
let cat = try callCat()
} catch let error as CatError { // would not compile, because you catch something that is not thrown
}
You can now update your catch
clauses to make sure that you are aware of the new error cases and that you handle them properly.
throws
is made for imperative languages
Let's compare the two approaches from above to throws
, if it would be usable with specific errors.
Approach 3: throws
with specific error
func stringFromArray(_ array: [String], at index: Int, errorMessage: String) throws GenericError -> String {
guard array.indices.contains(index) else { throw GenericError(message: errorMessage) }
return array[index]
}
func userResultFromStrings(strings: [String]) throws GenericError -> User {
let firstName = try stringFromArray(strings, at: 0, errorMessage: "Missing first name")
let lastName = try stringFromArray(strings, at: 1, errorMessage: "Missing last name")
return User(firstName: firstName, lastName: lastName)
}
The error handling mechanism is pushed aside and you can see the domain logic more clearly.
Error type conversions
Be aware that in all 3 approaches we are omitting the issue of simplifying error type conversions, which can be a topic for another proposal. But here's how it would look like for Approach 1 and 3 without further language constructs.
Example is taken from Question/Idea: Improving explicit error handling in Swift (with enum operations) - Using Swift - Swift Forums.
Approach 1:
struct FirstNameError: Swift.Error {}
func firstNameResultFromArray(_ array: [String]) -> Result<String, FirstNameError> {
guard array.indices.contains(0) else { return Result.failure(FirstNameError()) }
return Result.success(array[0])
}
func userResultFromStrings(strings: [String]) -> Result<User, GenericError> {
return firstNameResultFromArray(strings)
.map { User(firstName: $0, lastName: "") }
.mapError { _ in
// Mapping from `FirstNameError` to a `GenericError`
GenericError(message: "First name is missing")
}
}
Approach 3:
func firstNameResultFromArray(_ array: [String]) throws FirstNameError -> String {
guard array.indices.contains(0) else { throw FirstNameError() }
return array[0]
}
func userResultFromStrings(strings: [String]) throws GenericError -> User {
do {
let firstName = try stringFromArray(strings, at: 0, errorMessage: "Missing first name")
return User(firstName: firstName, lastName: "")
} catch let error as FirstNameError {
// Mapping from `FirstNameError` to a `GenericError`
throw GenericError(message: "First name is missing")
}
}
An example with multiple errors can be found here:
Typed throw functions - #122 by torstenlehmann.
Detailed design
Syntax adjustments
We are referring to Summary of the Grammar — The Swift Programming Language (Swift 5.3).
Adding
**throws**-clause -> **throws** type-identifier(opt)
to the grammar.
Function type
Changing from
function-type → attributes(opt) function-type-argument-clause **throws**(opt) -> type
to
function-type → attributes(opt) function-type-argument-clause **throws**-clause(opt) -> type
Examples
() -> Bool
() throws -> Bool
() throws CatError -> Bool
Closure expression
Changing from
closure-signature → capture-list(opt) closure-parameter-clause **throws**(opt) function-result opt in
to
closure-signature → capture-list(opt) closure-parameter-clause **throws**-clause(opt) function-result opt in
Examples
{ () -> Bool in true }
{ () throws -> Bool in true }
{ () throws CatError -> Bool in true }
Function declaration
Changing from
function-signature → parameter-clause **throws**(opt) function-result(opt)
to
function-signature → parameter-clause **throws**-clause(opt) function-result(opt)
Examples
func callCat() -> Cat
func callCat() throws -> Cat
func callCat() throws CatError -> Cat
Protocol initializer declaration
Changing from
protocol-initializer-declaration → initializer-head generic-parameter-clause(opt) parameter-clause **throws**(opt) generic-where-clause(opt)
to
protocol-initializer-declaration → initializer-head generic-parameter-clause(opt) parameter-clause **throws**-clause(opt) generic-where-clause(opt)
Examples
init()
init() throws
init() throws CatError
Initializer declaration
Changing from
initializer-declaration → initializer-head generic-parameter-clause(opt) parameter-clause **throws**(opt)
to
initializer-declaration → initializer-head generic-parameter-clause(opt) parameter-clause **throws**-clause(opt)
Examples
init()
init() throws
init() throws CatError