A Tale of Two Error Types in Swift

Luis Recuenco
The Swift Cooperative
8 min read3 days ago
Photo by David Pupăză on Unsplash

Introduction

I remember the first time I read about Railway Oriented Programming. It was back in September 2015. We already had Swift 2.0 with its new error-handling model, allowing us to annotate any function as throws. That new way of handling errors had some issues though:

  1. It was impossible to use with asynchronous functions and callbacks.
  2. It was untyped. When I started learning Swift, I wanted to type everything strongly! I wanted the compiler to prevent as many errors as possible, have exhaustive error checking, etc. Years later, I realized that throwing untyped errors is usually the most ergonomic way to handle errors in most scenarios.
  3. Coming from Objective-C, where exceptions had different semantics, and weren’t used for normal error control flow (but for exceptional fatal programming errors at runtime), it was hard to embrace this new way of thinking about errors. We no longer throw Exceptions, we throw Errors now. And we are talking about “normal” errors as we can read in the Swift blog.
  4. The impact of marking a function as throws is not negligible and will force you to either handle it in every usage of that function or mark the calling function as throws. This means that refactoring code to start throwing errors might require quite a lot of changes across your code base. If you come from the Java world, this is similar to checked exceptions, but without the specific error type. And well, in the Java community, these kinds of exceptions are considered a bad practice.

The Result type was embraced by the community back then as the idiomatic way to solve the first two problems. It could be used in asynchronous contexts, it avoided impossible states (like having both an error and a success value) and we could decide to type the error if needed.

Even if we didn’t have the Result type yet (it wouldn’t be included in the standard library until 2019 with Swift 5.0), we could very easily build it ourselves. Enums with associated values were available in the first version of Swift, and they were the fundamental building block for a lot of the new type safety possibilities that Swift brought us:

enum Result<Value, Failure> where Failure: Error {
case success(Value)
case failure(Failure)
}

Throughout the article, we’ll develop a very simple example to see the pros and cons of different error mechanisms available in Swift. From the previous Result type, to the newer typed throws available in Swift 6.

Result type

Let’s imagine that we are developing a function that:

  • Downloads some integer value from an API.
  • Sends an email using that previous integer value.

Both steps can error of course.

enum APIError: Error {
case unknown
}

enum EmailError: Error {
case unknown
}

enum APIOrEmailError: Error {
case api(APIError)
case email(EmailError)
}

func downloadData(callback: (Result<Int, APIError>) -> Void) {
fatalError("Implementation is not important")
}

func sendEmail(value: Int, callback: (Result<Void, EmailError>) -> Void) {
fatalError("Implementation is not important")
}

func downloadDataAndSendEmail(callback: (Result<Void, APIOrEmailError>) -> Void) {
downloadData { result in
switch result {
case .success(let value):
sendEmail(value: value) { result in
switch result {
case .success:
callback(.success(()))
case .failure(let error):
callback(.failure(.email(error)))
}
}

case .failure(let error):
callback(.failure(.api(error)))
}
}
}

As you can see, we have two main issues:

  • Callbacks don’t compose nicely, so code gets ugly very easily, leading us to a hard-to-read-and-maintain pyramid of doom with lots of nested levels of indentation.
  • We need to interpret explicitly the errors from the different layers and decide how we want to expose them upwards. In this case, we simply forwarded both the API and email errors as part of the associated values in the compound error. Take into account that any time we introduce new errors, all usages of the downloadDataAndSendEmail function will break. That’s the price to pay for the error type safety and that’s why library authors should prefer untyped errors in most cases.

But even if we remove the explicit types and simply have Error in the Result types, the implementation is still hard to read and doesn’t compose very well.

Let’s look for some alternatives.

Combine

The aforementioned Railway Oriented Programming was the solution to the first of our problems. It was a solution to compose functions that can fail in a nice way. In order to do so, we need to two pieces:

  1. A type that represents an asynchronous computation that can fail.
  2. A flatMap and mapError combinators to chain functions returning those types.

At that time, we didn’t have Combine yet (it would be introduced in 2019), but we could very easily create an asynchronous version of that result type like this:

struct AsyncResult<Value, Failure> where Failure: Error {
var run: ((Result<Value, Failure>) -> Void) -> Void

// Some combinators like flatMap, mapError, etc
}

For simplicity, let’s just use Combine for the example

func downloadData() -> AnyPublisher<Int, APIError> {
fatalError("Implementation is not important")
}

func sendEmail(value: Int) -> AnyPublisher<Void, EmailError> {
fatalError("Implementation is not important")
}

func downloadDataAndSendEmail() -> AnyPublisher<Void, APIOrEmailError> {
downloadData()
.mapError(APIOrEmailError.api)
.flatMap { sendEmail(value: $0).mapError(APIOrEmailError.email) }
.eraseToAnyPublisher()
}

Things look much better! Well… it depends 😅. Even if the code is succinct, there are several underlying Combine’s operators that we needed to use: mapError, flatMap. Even if that code might read well if you are used to Combine and reactive paradigms, writing that code is not straightforward.

What happens if we remove the typed errors? If both downloadData and sendEmail still return APIError and EmailError, the implementation is still quite complex:

func downloadDataAndSendEmail() -> AnyPublisher<Void, any Error> {
downloadData()
.mapError { $0 }
.flatMap { sendEmail(value: $0).mapError { $0 } }
.eraseToAnyPublisher()
}

Fortunately, if we are lucky enough to control downloadData and sendEmail functions to change their signatures to use any Error , things get much better:

func downloadDataAndSendEmail() -> AnyPublisher<Void, any Error> {
downloadData()
.flatMap(sendEmail)
.eraseToAnyPublisher()
}

No need for ugly mapError functions and things compose much nicer. Just a single flatMap function.

We can even simplify things further by removing AnyPublisher and using the Publisher existential type directly.

func downloadDataAndSendEmail() -> any Publisher<Void, any Error> {
downloadData().flatMap(sendEmail)
}

As you can see. Removing the specific error type from all the steps in the chain had huge implications for the composability and readability of the code.

Let’s compare this implementation with a final one. Arguably, the most idiomatic way of handling errors in Swift, via throwing functions.

Async throwing functions

func downloadData() async throws(APIError) -> Int {
fatalError("Implementation is not important")
}

func sendEmail(value: Int) async throws(EmailError) {
fatalError("Implementation is not important")
}

func downloadDataAndSendEmail() async throws(APIOrEmailError) {
let value: Int
do {
value = try await downloadData()
} catch {
throw .api(error)
}

do {
try await sendEmail(value: value)
} catch {
throw .email(error)
}
}

Even if there are quite a few mappings of errors via the do/catch clauses, I read that much better than this other version:

downloadData()
.mapError(APIOrEmailError.api)
.flatMap { sendEmail(value: $0).mapError(APIOrEmailError.email) }
.eraseToAnyPublisher()

But readability is quite subjective and depends a lot on people’s experience and preferences…

Let’s see what happens now if we decide to remove the typed error just from the downloadDataAndSendEmail function.

func downloadDataAndSendEmail() async throws {
let value = try await downloadData()
try await sendEmail(value: value)
}

You can see here the great ergonomics that Typed throws provides in Swift. Even if downloadData and sendEmail still throw typed errors, having several functions throwing different typed errors means that the enclosing function throws any Error, which is great and makes a lot of sense. No need to handle ugly error mapping!

This solves one of the main problems of checked exceptions in Java. Each time you use a function that throws a checked exception, you are forced to either change your signature to account for that new exception or just catch it and rethrow it in a way that you don’t change your function’s signature. Swift handles this scenario much better.

func sendEmail(value: Int) async throws(EmailError) {
fatalError()
}

func downloadDataAndSendEmail() async throws {
// It doesn't matter if sendEmail throws a typed error.
// As `downloadDataAndSendEmail` throws any Error, this typed
// error is automatically converted to `any Error`
try await sendEmail(value: 3)
}

Better error semantics

Even if you might think that you won’t use typed throws in your code base much, which might be true for app development to be honest, typed throws allows Swift to have much better error semantics.

Let’s imagine we want to model a repository generic over a Model type, with an async sequence that returns those types over time. In Swift 5.10, with untyped throws, this is the best we can do.

protocol Repository<Model> {
associatedtype Model: Equatable
associatedtype ModelSequence: AsyncSequence where ModelSequence.Element == Model

var modelSequence: ModelSequence { get }
}

extension Repository {
func onModel(action: @escaping (Model) -> Void) {
Task {
for try await model in modelSequence {
action(model)
}
}
}
}

There’s not a big problem with that code, but there’s no way to convey that the async sequence never fails. That means that iterating over that sequence in the onModel function will always force us to use for try await, even for cases where that sequence would never fail.

And this is because of how AsyncIteratorProtocol is defined:

protocol AsyncIteratorProtocol {
associatedtype Element

mutating func next() async throws -> Self.Element?
}

As you can see, the next function is async throws and the only way for Swift to know that an async iterator won’t throw is to know the specific implementation of AsyncIteratorProtocol with a non-throwing next function.

In Swift 6, things get much better:

protocol Repository<Model> {
associatedtype Model: Equatable
associatedtype ModelSequence: AsyncSequence<Model, Never>

var modelSequence: ModelSequence { get }
}

extension Repository {
func onModel(action: @escaping (Model) -> Void) {
Task {
for await model in modelSequence {
action(model)
}
}
}
}

We can now leverage the primary associated types in AsyncSequence and use the Never type to ensure that the async sequence will never fail. That means that the onModel function can iterate over the sequence with just for await instead of for try await. And that’s because AsyncIteratorProtocol is defined as follows:

protocol AsyncIteratorProtocol<Element, Failure> {
associatedtype Element

mutating func next(isolation actor: isolated (any Actor)?) async throws(Self.Failure) -> Self.Element?
}

As you can see, we can use the Failure primary associated type in the function signature via throws Failure. And throws Never will be the same as not having the throws word at all.

This might seem like a negligible small thing that won’t have any impact in your code. That might be true, but Swift getting smarter about how to handle errors won’t hurt 😅.

Conclusion

In general, I think typed throws is a very welcome addition to the Swift language. It doesn’t break existence code bases and allows developers to choose the best way to model errors (either typed or untyped) while keeping great ergonomics and avoiding the problems we had in other languages like Java with checked exceptions.

Library authors would still need to be wary about using typed errors because of the evolution problems, but for other type of code, it gives us some great flexibility and would allow the compiler to force several error code paths for specific, important domain errors where we want that extra type safety.

Looking forward to Swift 6.

--

--

Luis Recuenco
The Swift Cooperative

iOS at @openbank_es. Former iOS at @onuniverse, @jobandtalent_es, @plex and @tuenti. Creator of @ishowsapp.