1.8 — Error handling: throw, try, Result, and when to use which
Opening scenario
You inherit a screen with this code:
func loadProfile(id: String) async -> Profile? {
do {
let data = try await api.fetch(id: id)
let profile = try JSONDecoder().decode(Profile.self, from: data)
return profile
} catch {
return nil
}
}
The UI shows “Profile not found” when anything goes wrong: network down, JSON malformed, server returned a 401, the user is offline. The product manager files a bug: “users say the app lies about errors.” You agree. You also agree, after reading this chapter, that the entire catch block above is a category of bug. Let’s learn how to do better.
Concept → Why → How → Code
Concept: Swift errors are values, marked at the function signature
Three actors collaborate:
- The
Errorprotocol — any type can be an error (usually an enum). - The
throwskeyword on a function — says “this function may throw an error.” - The
trykeyword at call sites — says “I acknowledge this might throw.”
enum NetworkError: Error {
case offline
case timeout
case unauthorized
case server(status: Int)
}
func fetch(_ url: URL) throws -> Data {
// … throws NetworkError.offline / .timeout / etc.
throw NetworkError.offline
}
do {
let data = try fetch(myURL)
process(data)
} catch NetworkError.unauthorized {
showLogin()
} catch NetworkError.server(let status) where status >= 500 {
showRetry()
} catch {
showGenericError(error)
}
Why this design
Other languages: errors are exceptions (Java, Python) — unannotated, can come from anywhere, often abused for control flow. Or: errors are return values (Go, Rust) — typed, but verbose at every call site.
Swift splits the difference: errors are values, but the syntax (try/throws) keeps call sites readable. The compiler forces you to handle them — you cannot accidentally swallow an error by forgetting a catch.
How: the four error-handling tools
// 1. do / try / catch — handle locally
do {
let user = try loadUser()
show(user)
} catch {
showError(error)
}
// 2. try? — convert to optional (nil on failure)
let user: User? = try? loadUser()
// 3. try! — force "I know this won't throw" (CRASHES if wrong)
let bundleURL = try! Bundle.main.url(forResource: "config", withExtension: "json")!
// 4. throws propagation — let the caller deal with it
func handler() throws -> User {
try loadUser() // re-throws automatically
}
try? is the analog of as?: it gives you Optional<T> and loses the error detail.
Code: typed throws (Swift 6+)
Until Swift 6, every throws function could throw any Error. Now you can constrain it:
func fetch(_ url: URL) throws(NetworkError) -> Data { … }
do {
let data = try fetch(url)
} catch NetworkError.offline { … } // exhaustive — compiler enforces
Typed throws are still being adopted across the ecosystem; many APIs remain untyped. Use them in your own code when the error set is small and stable.
Code: Result — when you need an error value, not an effect
public enum Result<Success, Failure: Error> {
case success(Success)
case failure(Failure)
}
func fetchResult(_ url: URL) -> Result<Data, NetworkError> {
do {
let data = try fetch(url)
return .success(data)
} catch let e as NetworkError {
return .failure(e)
} catch { return .failure(.offline) }
}
let r = fetchResult(url)
switch r {
case .success(let data): process(data)
case .failure(let err): handle(err)
}
When do you reach for Result over throws?
throwsfor synchronous-feeling code paths — the call site reads naturally withtry.Resultfor storing or passing errors as values — caching the outcome of an async op, queueing results, returning from callbacks.Resultinterops nicely with Combine and older callback APIs:(Result<T, Error>) -> Voidcompletion handlers were standard beforeasync/await.
Code: async errors
async and throws compose naturally:
func loadUser(id: String) async throws -> User {
let data = try await api.fetch(id: id)
return try JSONDecoder().decode(User.self, from: data)
}
// Call site
Task {
do {
let user = try await loadUser(id: "ada")
await MainActor.run { self.user = user }
} catch {
await MainActor.run { self.error = error }
}
}
try await is read “try-await”: acknowledge the throw AND the suspension. Order matters in declaration (async throws) but at the call site try await is the only legal order.
In the wild
URLSession.shared.data(from: url)throws. It’s anasync throwsfunction returning(Data, URLResponse). Every network call you make in modern Swift is wrapped intry await.Codabledecoding throws.JSONDecoder().decode(...)returns the decoded value or throws aDecodingError(which is itself a rich enum —.keyNotFound,.typeMismatch, etc.).- File I/O (
String(contentsOf: url),FileManagermethods) throws. Task.checkCancellation()throwsCancellationError— the standard way to bail out of a long-running async task.
Common misconceptions
-
“
try?is the lazy programmer’s way out.” Not quite — it’s appropriate when you genuinely don’t care why an operation failed, only whether it succeeded. The bug in the opening scenario isn’t usingtry?; it’s not distinguishing a 401 from a parse error. -
“Errors should always be enums.” Most should — exhaustive switches at the catch site are valuable. But for opaque errors (libraries you can’t predict),
Erroritself is fine. For user-facing errors, conform toLocalizedErrorto provideerrorDescription. -
“
throwsis slow because of exception unwinding.” Swift’s error handling does not use exception unwinding. It’s compiled to a normal return-value path with a discriminator. Cost is comparable to returning aResult. -
“Every function should
throws.” No —throwsis part of the function’s contract. Make a functionthrowsonly when it genuinely can fail in ways the caller should handle. Afunc add(_ a: Int, _ b: Int) -> Intshould never throw. -
“
fatalErroris the same asthrow.” Profoundly not.throwis recoverable;fatalErrorterminates the process. UsefatalErroronly for “this is a programmer bug, I want a crash with a clear message” cases — typically ininit?failures that shouldn’t be possible.
Seasoned engineer’s take
The most important habit: let errors travel as far as the layer that knows how to handle them, and no further.
- Networking layer: throws
NetworkError. - Repository / domain layer: maps
NetworkErrorto domain errors (UserError.notLoggedIn,UserError.networkUnavailable). - UI layer: maps domain errors to user-visible state (“Sign in to continue”, “Check your connection”).
Don’t catch-and-swallow errors in middle layers. Don’t print(error) and continue. Don’t replace every catch with a single “Oops, something went wrong” screen — that’s the antipattern in the opening scenario. The error type is your domain language for failure, and you should use it.
A second habit: use enums with associated values for errors, so the kind of failure carries the data needed to recover from it:
enum UploadError: Error {
case quotaExceeded(currentMB: Int, limitMB: Int)
case fileTooLarge(maxBytes: Int)
case networkLost(retryAfter: Duration)
case serverRejected(reason: String)
}
The catch site has everything it needs to compose a helpful UI (“You’re 50 MB over your 200 MB quota. Upgrade?”).
TIP: Conform your error enums to
LocalizedErrorand implementerrorDescriptionto geterror.localizedDescriptionfor free, ready forText(error.localizedDescription)in SwiftUI.
WARNING: Do not rethrow errors at module boundaries without thinking. If your domain layer rethrows a
URLErrorto the UI, the UI now depends on networking concretely. Map errors at the boundary.
Interview corner
Question: “Walk me through error handling in modern Swift. When would you use throws vs Result vs returning an optional?”
Junior answer: “throws is when something can fail, Result is for async, Optional is when the value might not exist.” → Roughly true. They’ll dig deeper.
Mid-level answer: “I use throws by default for synchronous code paths where the call site benefits from try. I reach for Result when I need to store the outcome (for example caching the latest fetch state) or when integrating with callback-style APIs. I return Optional only when ‘nothing’ is a normal, non-error outcome — like a lookup that legitimately may not find a value. The distinction I make is: an error means something abnormal happened; a nil means the absence of value was expected.” → Strong. The last sentence is what interviewers want to hear.
Senior answer: Everything above, plus: “I’d also talk about error modeling. The choice of error type defines the API’s reliability contract. I’d design error enums with associated values that carry recovery data — case quotaExceeded(used: Int, limit: Int) is more useful than case quotaExceeded. I’d map errors at architecture boundaries — network errors don’t leak into the UI layer unchanged. And I’d be cautious about typed throws (Swift 6 feature): they’re great for stable error sets but lock you into the type — adding a case is a breaking change. For library code that’s published, I usually stay with untyped throws and document the error type in the docs.” → Senior signal: thinks about API design and evolution.
Red-flag answer: “I wrap every operation in do { try … } catch { print(error) }.” → That’s the bug from the opening scenario. Tells the interviewer you swallow errors silently in production.
Lab preview
Lab 1.D (Async fetcher) makes you implement a small network client with a real error type — distinguishing offline from server-rejected from decode-failure. You’ll wire each error variant to a different UI state.
Next: the chapter the whole language was redesigned around — concurrency, async/await, and actors. → Concurrency