Error handling in Swift

How to use do-catch statements and throw custom errors to alert users (with code examples)

Bruno Lorenzo
5 min readJan 9, 2024

Error handling is a fundamental part of every app.

Not every action that users perform will succeed. Some will fail, and our app needs to tell our users, in an easy way, what just happened, and what they could try to avoid the issue.

Let me show you how we can achieve some simple error handling using throw and do-catch statements.

Image by Author

Defining & Throwing errors

To represent an error, we must use a type that conforms Error protocol. This is an empty protocol, which means that there is no constraint to it and that we can define errors as any type that suits us best.

In general, enums are the best way to start. Suppose that we want to handle network errors, we could define the following enumπŸ‘‡

enum NetworkError: Error {
case unexpected
case invalidURL(_ url: URL)
case apiError(statusCode: Int)
}

If we get an error when performing a request to our backend API, we can throw a NetworkError.

The function that will throw the error must be marked with the throw keyword

enum ApiMethod {...}
struct ApiRequest {...}

func createRequest(from url: String, method: ApiMethod) throws -> ApiRequest {
guard let URL = URL(string: url) else {
throw NetworkError.invalidURL(url)
}
...
}

Catching errors

We have our custom errors, and we're throwing them, but how do we capture them?

Like many other languages, Swift has a do-catch statement. If an error is thrown inside the do clause, the execution is transferred to the catch clause and the error is stored in a local constant error.

do {
let result = try createRequest(from: "//myapi.com/", method: .GET)
} catch {
print(error)
// Do a proper handling
}

Notice that, as the function may throw an error, we must use the keyword try before the call

Sometimes we want to trigger specific actions depending on the error type (or error value) we caught. We can do this using Patterns.

do {
let result = try makeRequest(to: "//myapi.com/", method: .GET)
} catch NetworkError.invalidUrl(let url) {
// ...
} catch {
print(error)
//
}

Propagating errors

Throwing functions can propagate errors that are thrown inside the function's scope, to its caller's scope.

struct User: Decodable {
let name: String
let age: Int
}

func decodeJSON<T: Decodable>(_ jsonString: String) throws -> T {
let jsonData = Data(jsonString.utf8)
let decodedObject = try JSONDecoder().decode(T.self, from: jsonData)
return decodedObject
}

func getUserFromJSON(_ jsonString: String) -> User? {
do {
let user: User = try decodeJSON(jsonString)
return user
} catch {
print(error)
return nil
}
}

try? & try!

Sometimes we don't care about the error that a throwing function gives us. In this scenario, we can use try? instruction without needing to use a do-catch statement.

Using try? will make the calling function optional. This means that if we're calling a function that returns a value, the value will be nil.

let request = try? createRequest(from: "malformedurl", method: .GET)
// request = nil

On the other hand, sometimes we will know for sure that, a particular throwing function won't fail at runtime. In these cases, we can use try!. And, as withtry?, we don't need to use do-catch closure.

let request = try! createRequest(from: "https://myapi.com/", method: .GET)

Async code

Throwing errors works just fine together with asynchronous code. We need to put the async keyword before the throws keyword in the function declaration.

When calling the async function, we use try await (keyword order swipe).

func performRequest(with request: ApiRequest) async throws -> ApiResult {...}

// ----------------

do {
let request = try makeRequest(to: "https://myapi.com/users", method: .GET)
let apiRestul = try await performRequest(with: request)
} catch NetworkError.invalidUrl(let url) {
// ...
} catch {
print(error)
//
}

Alert Users

We have our error structure in place, now we need to tell our users that something wrong happened.

To simplify things, we will use the default iOS alert, but you can craft your own alert UI if you like.

For each error that we might present, we will need:

  • A description of why the error happened in the first place.
  • A possible action users could do to avoid the user.

We could add that information as variables in our Error enum. However, we already have a protocol to group all this information: LocalizedError. For our use case, we only need to implement failureReason & recoverySuggestion variables.

enum OrderError: LocalizedError {
case unexpected
case outOfCoffee(coffee: Coffee)
case minimumNotMet(currentPrice: Float)

var failureReason: String? {
switch self {
case .unexpected:
return "We had a problem making your order"
case .outOfCoffee(let coffee):
return "We ran out of \(coffee.name)"
case .minimumNotMet:
return "You need to reach at least $10 to order from the app"
}
}

var recoverySuggestion: String? {
switch self {
case .unexpected:
return "Try again in a few minutes"
case .outOfCoffee:
return "Change your coffee for another one"
case .minimumNotMet(let currentPrice):
return "Add $\(10-currentPrice) more to your order to continue"
}
}
}

This will do the work. But, if we have a logging solution integrated with our app, we can extend our error structure to add a little more information.

I'm using HKLogger, an open-source library we created at Houlak for managing logging in our iOS apps.

import Foundation
import HKLogger

struct AppError: Error {
let type: LocalizedError
var userMessage: String {
return "\(type.failureReason ?? ErrorConstants.defaultError) \n\n \(type.recoverySuggestion ?? ErrorConstants.defaultAction)"
}
// Add as many properties as you need

init(type: LocalizedError, debugInfo: String? = nil) {
self.type = type
guard let debugInfo else { return }
HKLogger.shared.error(message: debugInfo)
}
}

This way, we centralize our logging error logic in one place.

Finally, we can create a simple View extension to present the alert with a custom AppError.

extension View {
func alert(isPresented: Binding<Bool>, withError error: AppError?) -> some View {
return alert(
"Ups! :(",
isPresented: isPresented,
actions: {
Button("Ok") {}
}, message: {
Text(error?.userMessage ?? "")
}
)
}
}

// ---------------------------------------------------

struct OrderConfirmationView: View {
@StateObject private var viewModel = ConfirmationViewModel()

var body: some View {
VStack(alignment: .leading) {
// ...
Button("Place Order") {
viewModel.tryToPlaceOrder(order)
}
}
.alert(isPresented: $viewModel.showError, withError: viewModel.appError)
}
}

// ---------------------------------------------------

@Observable
final class ConfirmationViewModel: ObservableObject {
var appError: AppError?
var showError = false

func tryToPlaceOrder(_ order: Order) {
do {
try OrdersManager.shared.add(order)
} catch let error as AppError {
showError = true
appError = error
} catch {
appError = AppError(type: OrderError.unexpected)
}
}
}

// ---------------------------------------------------

final class OrdersManager {
// ...
func add(_ order: Order) throws {
if order.price >= 10 {
orders.append(order)
} else {
throw AppError(type: OrderError.minimumNotMet(currentPrice: order.price))
}
}
}

Have any questions? Feel free to drop me a message! πŸ™‚

  • πŸ€“ Join me on X for regular content on iOS development tips and insights
  • πŸš€ Check out my GitHub where I share all my example projects (this one included πŸ˜‰)

--

--

Bruno Lorenzo

Software Engineer | Innovation Manager at www.houlak.com | Former iOS Tech Lead | I write about iOS, tech, and producitivy