Designing APIs with typed throws in Swift

Published on: February 22, 2024

When Swift 2.0 added the throws keyword to the language, folks were somewhat divided on its usefulness. Some people preferred designing their APIs with an (at the time) unofficial implementation of the Result type because that worked with both regular and callback based functions.

However, the language feature got adopted and a new complaint came up regularly. The way throws in Swift was designed didn’t allow developers to specify the types of errors that a function could throw.

In every do {} catch {} block we write we have to assume and account for any object that conforms to the Error protocol to be thrown.

This post will take a closer look at how we can write catch blocks to handle specific errors, and how we can leverage the brand new types throws that will be implemented through SE-0413 recently.

Let’s dig in!

If you prefer to watch this content as a video, the video is available on YouTube:

The situation today: catching specific errors in Swift

The following code shows a standard do { } catch { } block in Swift that you might already be familiar with:

do {
  try loadfeed()
} catch {
  print(error.localizedDescription)
}

Calling a method that can throw errors should always be done in a do { } catch { } block unless you call your method with a try? or a try! prefix which will cause you to ignore any errors that come up.

In order to handle the error in your catch block, you can cast the error that you’ve received to different types as follows:

do {
  try loadFeed()
} catch {
  switch error {
  case let authError as AuthError:
    print("auth error", authError)
    // present login screen
  case let networkError as NetworkError:
    print("network error", networkError)
    // present alert explaining what went wrong
  default:
    print("error", error)
    // present generic alert with a message
  }
}

By casing your error in the switch statement, you can have different code paths for different error types. This allows you to extract information from the error as needed. For example, an authentication error might have some specific cases that you’d want to inspect to correctly manage what went wrong.

Here’s what the case for AuthError might end up looking like:

case let authError as AuthError:
  print("auth error", authError)

  switch authError {
  case .missingToken:
      print("missing token")
      // present a login screen
  case .tokenExpired:
    print("token expired")
    // attempt a token refresh
  }

When your API can return many different kinds of errors you can end up with lots of different cases in your switch, and with several levels of nesting. This doesn’t look pretty and luckily we can work around this by defining catch blocks for specific error types.

For example, here’s what the same control flow as before looks like without the switch using typed catch blocks:

do {
  try loadFeed()
} 
catch let authError as AuthError {
  print("auth error", authError)

  switch authError {
  case .missingToken:
      print("missing token")
      // present a login screen
  case .tokenExpired:
    print("token expired")
    // attempt a token refresh
  }
} 
catch let networkError as NetworkError {
  print("network error", networkError)
  // present alert explaining what went wrong
} 
catch {
  print("error", error)
}

Notice how we have a dedicated catch for each error type. This makes our code a little bit easier to read because there’s a lot less nesting.

The main issues with out code at this point are:

  1. We don’t know which errors loadFeed can throw. If our API changes and we add more error types, or even if we remove error types, the compiler won’t be able to tell us. This means that we might have catch blocks for errors that will never get thrown or that we miss catch blocks for certain error types which means those errors get handles by the generic catch block.
  2. We always need a generic catch at the end even if we know that we handle all error types that our function cold probably throw. It’s not a huge problem, but it feels a bit like having an exhaustive switch with a default case that only contains a break statement.

Luckily, Swift proposal SE-0413 will fix these two pain points by introducing typed throws.

Exploring typed throws

At the time of writing this post SE-0413 has been accepted and is available using the upcoming feature flag FullTypedThrows. If you're interested in exploring upcomng Swift Features, you can do so by installing an experimental toolchain. Learn how you can do that in This post

At its core, typed throws in Swift will allow us to inform callers of throwing functions which errors they might receive as a result of calling a function. At this point it looks like we’ll be able to only throw a single type of error from our function.

For example, we could write the following:

func loadFeed() throws(FeedError) {
  // implementation
}

What we can’t do is the following:

func loadFeed() throws(AuthError, NetworkError) {
  // implementation
}

So even though our loadFeed function can throw a couple of errors, we’ll need to design our code in a way that allows loadFeed to throw a single, specific type instead of multiple. We could define our FeedError as follows to do this:

enum FeedError {
  case authError(AuthError)
  case networkError(NetworkError)
  case other(any Error)
}

By adding the other case we can gain a lot of flexibility. However, that also comes with the downsides that were described in the previous section so a better design could be:

enum FeedError {
  case authError(AuthError)
  case networkError(NetworkError)
}

This fully depends on your needs and expectations. Both approaches can work well and the resulting code that you write to handle your errors can be much nicer when you have a lot more control over the kinds of errors that you might be throwing.

So when we call loadFeed now, we can write the following code:

do {
  try loadFeed()
} 
catch {
  switch error {
    case .authError(let authError):
      // handle auth error
    case .networkError(let networkError):
      // handle network error
  }
}

The error that’s passed to our catch is now a FeedError which means that we can switch over the error and compare its cases directly.

For this specific example, we still require nesting to inspect the specific errors that were thrown but I’m sure you can see how there are benefits to knowing which type of errors we could receive.

In the cases where you call multiple throwing methods, we’re back to the old fashioned any Error in our catch:

do {
  let feed = try loadFeed()
  try cacheFeed(feed)
} catch {
  // error is any Error here
}

If you’re not familiar with any in Swift, check out this post to learn more.

The reason we’re back to any Error here is that our two different methods might not throw the same error types which means that the compiler needs to drop down to any Error since we know that both methods will have to throw something that conforms to Error.

In Summary

Typed throws have been in high demand ever since Swift gained the throws keyword. Now that we’re finally about to get them, I think a lot of folks are quite happy.

Personally, I think typed throws are a nice feature but that we won’t see them used that much.

The fact that we can only throw a single type combined with having to try calls in a do block erasing our error back to any Error means that we’ll still be doing a bunch of switching and inspecting to see which error was thrown exactly, and how we should handle that thrown error.

I’m sure typed throws will evolve in the future but for now I don’t think I’ll be jumping on them straight away once they’re released.

Categories

Swift

Subscribe to my newsletter