Stop fighting with Swift's frustrating do-catch blocks. Catch errors simply.
Swift's error handling is powerful, safe and well-designed. But do
catch
blocks have meaningful flaws which leads to complex code reasoning, false sense of security, and unhandled errors.
To fully understand the problem you should read my deep dives here:
I'll summarize the main points here:
do
blocks with multipletry
statements are problematic because:- When the function errors and jumps to the
catch
block, it is not clear whichtry
statement caused the error. - (Most of the time) the error is untyped, so you have to dynamically cast it to the correct type before you can read and handle it.
- The Trapped Scope Problem The result of the
try
statement is not available outside of thedo
block, so in practice you end up putting more work in thedo
block (which only exacerbates the problem). - Thrown errors abruptly exit the
do
block, which creates multiple code paths you have to consider.
- When the function errors and jumps to the
- Swift actually has TWO type systems:
- The type system for the function signature including the return type.
- The type system for the thrown error.
[!WARNING] Not API Stable This library is currently in beta development and is not API stable. The API may change in the future without notice.
This library provides various tool to handle errors in a more ergonomic way through the following strategies:
Any function marked throws
effectively has two return types:
- The return type of the function
- The error type
This library provides convenient functions to convert the two return types into a single return type such as Optional
, Result
, or a default value.
catch
blocks do not tell you which try
statement caused the error. This library provides a way to handle errors in place without the need for a do
catch
block.
To convert throwing functions, the library not only accepts throwing closures, but also applies the @autoclosure
attribute. This is mainly for one reason: the @autoclosure
forces us to send in one and only one throwing function. This means that we know exactly what triggered our catch block.
This library operates under the philosophy each throwing function should be handled in one of two ways:
- Call the
try
function within athrows
context so that the error can "bubble up" and be handled by the appropriate caller. - Handle the error in place, immediately before proceeding so that we know exactly what caused the error.
Swift 6 introduced typed errors, which allows us to define the error type in the function signature. This library embraces this new feature and attempts to have feature parity between typed and untyped errors.
This library fully supports async
await
and provides the same functionality for async
functions.
This library provides safer strategies to run throwing functions without:
- creating a
throws
context - creating a
do
catch
block
Catch and handle your errors simply and ergonomically by:
- replacing the error with an
Optional
- replacing the error with a reasonable value
- bundling the error and value in a
Result
- simply handling the error in place
The library comes with a new initializer for Optional
which allows you to provide a closure
to read and handle the error. Then it's easy to unwrap it, just like any other Optional
.
func throwing() throws -> Int { 1 }
func printThrowing() {
guard let int = Optional(
for: try myStruct.succeeding(),
catcher: { error in
// handle the error here
}
) else {
// This is where we usually usually "handle" errors.
// Except we don't actually know what the error is because Swift doesn't
// give it to us here.
}
print(int) // 1
}
The obvious question is why not just use try?
. Here, try?
is definitely easier to read, write
and understand. But there is just one problem:
func printThrowing() {
guard let int = try? throwing() else {
// What's the error?
}
print(int) // 1
}
Where's the error? We don't have it because try?
never gives it to us. You have never actually handled
the error because you don't even know what it is. If you are confident that you can safely ignore
error then go ahead and use try?
. It's a lot more convenient. But if you want to actually handle the
error, then consider using the new Optional
initializer.
func throwing() throws -> Int { 1 }
func printThrowing() {
let int = value(default: -1, for: try throwing())
print(int) // 1
}
You can parse the error to decide on a replacement value.
func printThrowing() {
let int = value(
for: try throwing(),
replaceErrorWithValue: { e in
// decide how to handle the error here and
// replace with a reasonable value
}
)
print(int)
}
Another option is bundling it all in a Result
so that you can handle it elsewhere.
func printThrowing() {
let result = result(for: try throwing())
// result is `Result<Int, MyError>`
}
If your throwing function returns Void
then you can handle the Error
in place using doTry()
.
func printThrowing() {
doTry(
try throwing(),
catching: { e in
print(e)
}
)
}
You might be thinking, why not just use a do
catch
block here like this:
func printThrowing() {
do {
try throwing()
} catch {
print(error)
}
}
The truth is, a do
catch
is better here in most ways. It's shorter, simpler, and easier to read. Here doTry
has only one small advantage,
which is that doTry
should only be trying one throwing function. (Since doTry
accepts an @autoClosure
, this makes it very cumbersome to
accidentally try more than one throwing function.)
This means that we know exactly what triggered our catch block.
This library is currently in beta development and is not API stable. The API may change in the future without notice.
Please report any issues you find. In particular, I'm interested in your feedback on the API design. Also, the library supports typed and untyped errors, sync and async functions. Because of this there are many overloaded functions and generic types. Please let me know if the compiler infers an unexpected overload of a function, or if a type inference is missing.
It seems the Swift compiler is not able to infer the error type in some cases. Unfortunately, this severely impacts the ergonomics of the library. For example, the following code will not compile:
struct MyStruct: Sendable {
var int: Int
enum Error: Swift.Error, Equatable, Sendable {
case one, two, three, four
}
mutating func typedThrowing() throws(Self.Error) -> Int {
throw Error.three
}
}
func printThrowing() {
var myStruct = MyStruct(int: 1)
let result = Result(for: try myStruct.typedThrowing())
// Error: Generic parameter 'Failure' could not be inferred
}
Even though typedThrowing
has a typed error, and the library's Result
initializer passes the error type to Result
's generic Failure
type, the compiler is still unable to infer the error type.
As an unfortunate workaround, you can explicitly specify the error type in the Result
initializer.
let result = Result<Int, MyStruct.Error>(for: try myStruct.typedThrowing())
This error type inference problem also affects the Optional
initializer.
var myStruct = MyStruct(int: 1)
let typedSucceeding = Optional(
for: try myStruct.typedSucceeding(),
catcher: { (error: MyStruct.Error) in
Issue.record("This should not throw")
}
)
Here, we must explicitly declare the error type in the catcher
closure or else we lose the type information and Swift will treat it as any Error
. But we know for certain that the compiler already has the type information because if we explicitly specify the error type then the compiler immediately has the concrete error type without the need for runtime type casting.
This library seeks to solve language-level problems that really should be solved by the Swift language itself. The best solution I have found so far is this pitch:
The library is released under the MIT License.