Providing a unified Swift error API
Basics article available: Error HandlingI’d like to share a technique that I’ve come to find quite useful when using Swift’s do, try, catch
error handling model — to limit the amount of errors that can be thrown from a given API call.
At the moment, Swift does not yet support typed errors (which is sometimes known as “checked exceptions” in other languages, like Java), which means that any function that throws can potentially throw any error. While that gives us a lot of flexibility, it can also make our APIs a bit harder to use, both in production code and when writing tests.
Let’s start with an example
Consider the following function, which performs a search by synchronously loading data from a URL:
func loadSearchData(matching query: String) throws -> Data {
let urlString = "https://my.api.com/search?q=\(query)"
guard let url = URL(string: urlString) else {
throw SearchError.invalidQuery(query)
}
return try Data(contentsOf: url)
}
Our above function can throw in two different places — when attempting to construct a URL
, and also when loading its data at the bottom. That might not seem like a big deal, but it makes it difficult to know what kind of errors our API users can expect the function to throw. Not only would they need to be aware that this function uses the Data
type internally, but they’d also need to know what kind of errors that Data
’s initializer can throw.
Catching internal errors
Having to be aware of internal implementation details is usually a bad sign when it comes to API design — so wouldn’t it be better if we could guarantee that our function will only ever throw SearchError
errors? Luckily, that’s quite easily done. All we have to do is wrap our call to Data(contentsOf:)
in a do, try, catch
statement. Like this:
func loadSearchData(matching query: String) throws -> Data {
let urlString = "https://my.api.com/search?q=\(query)"
guard let url = URL(string: urlString) else {
throw SearchError.invalidQuery(query)
}
do {
return try Data(contentsOf: url)
} catch {
throw SearchError.dataLoadingFailed(url)
}
}
With the above change, we’re now silencing any error thrown by Data
, and then replacing it with our own error instead. That’ll let us document that our function always throws a SearchError
, and our API becomes a lot easier to use when it comes to error handling.
However, in making our API more clear, we’ve also cluttered our implementation up a bit. Often we’ll need to wrap multiple calls in do, try, catch
blocks, which could quickly make our code become harder to read. One way of solving that problem would be to introduce a simple function that does that wrapping for us, and throws a specific error that we control in case an underlying error was thrown — like this:
func perform<T>(_ expression: @autoclosure () throws -> T,
orThrow errorExpression: @autoclosure () -> Error) throws -> T {
do {
return try expression()
} catch {
throw errorExpression()
}
}
Above we’re using @autoclosure
, both to capture the actual expression at the call site (and, like the name implies, auto-wrap it in a closure), and to avoid having to execute the error expression in case no error was thrown.
Using the above perform
function, we can now update our original search function to avoid having to use any nested do, try, catch
statements — which’ll let us keep all of our code unindented, and make it easier to follow:
func loadSearchData(matching query: String) throws -> Data {
let urlString = "https://my.api.com/search?q=\(query)"
guard let url = URL(string: urlString) else {
throw SearchError.invalidQuery(query)
}
return try perform(Data(contentsOf: url),
orThrow: SearchError.dataLoadingFailed(url))
}
We’ve now managed to still provide a unified error API, but without making our implementation harder to read or understand — pretty cool! 😎
Preserving underlying errors
However, while we’ve made our API simpler through the above changes, we’ve also removed access to some potentially valuable debugging information — since our API users are no longer able to see any underlying error that caused the function to fail. That can be a bit frustrating when debugging, since we’d either need to rely on trial and error to figure things out — or go back to inspecting and stepping through the implementation (which was what we were trying to avoid in the first place).
While there are cases when completely silencing any underlying errors is appropriate — let’s extend our perform
utility from before to also support preserving, and capturing, any underlying error that occurred. To do that, we’ll add a second overload of perform
that instead of taking a replacement error to throw, accepts an errorTransform
closure that’ll let us convert any internal error into a public type:
func perform<T>(_ expression: @autoclosure () throws -> T,
errorTransform: (Error) -> Error) throws -> T {
do {
return try expression()
} catch {
throw errorTransform(error)
}
}
With the above in place, let’s update our search function one last time to both still have a unified error API, but now also exposing any underlying data loading error for increased debuggability:
func loadSearchData(matching query: String) throws -> Data {
let urlString = "https://my.api.com/search?q=\(query)"
guard let url = URL(string: urlString) else {
throw SearchError.invalidQuery(query)
}
return try perform(Data(contentsOf: url)) { error in
return SearchError.dataLoadingFailed(url, underlying: error)
}
}
Since Swift supports function overloading, we’re able to keep both our original implementation of perform
and this new one, since they accept different parameters. That’s great, because it’ll let us choose on a case-by-case basis whether we want to silence any underlying errors, or capture them.
Conclusion
Great API design is not only about what we name our functions, or what kind of data they return — but also about how they behave when something goes wrong, and perhaps even more importantly, how we deal with that kind of situation.
By providing a more unified error API, we can make it easier for callers of our APIs to make the right decisions on how to handle various errors, since they’ll get more granular information as to what kind of error that was encountered.
Feel free to reach out to me on Twitter or email if you have any questions, suggestions or feedback.
Thanks for reading! 🚀