Error handling in Swift
How to use do-catch statements and throw custom errors to alert users (with code examples)
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.
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))
}
}
}