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:

  1. The Error protocol — any type can be an error (usually an enum).
  2. The throws keyword on a function — says “this function may throw an error.”
  3. The try keyword 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?

  • throws for synchronous-feeling code paths — the call site reads naturally with try.
  • Result for storing or passing errors as values — caching the outcome of an async op, queueing results, returning from callbacks.
  • Result interops nicely with Combine and older callback APIs: (Result<T, Error>) -> Void completion handlers were standard before async/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 an async throws function returning (Data, URLResponse). Every network call you make in modern Swift is wrapped in try await.
  • Codable decoding throws. JSONDecoder().decode(...) returns the decoded value or throws a DecodingError (which is itself a rich enum — .keyNotFound, .typeMismatch, etc.).
  • File I/O (String(contentsOf: url), FileManager methods) throws.
  • Task.checkCancellation() throws CancellationError — the standard way to bail out of a long-running async task.

Common misconceptions

  1. 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 using try?; it’s not distinguishing a 401 from a parse error.

  2. “Errors should always be enums.” Most should — exhaustive switches at the catch site are valuable. But for opaque errors (libraries you can’t predict), Error itself is fine. For user-facing errors, conform to LocalizedError to provide errorDescription.

  3. throws is 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 a Result.

  4. “Every function should throws.” No — throws is part of the function’s contract. Make a function throws only when it genuinely can fail in ways the caller should handle. A func add(_ a: Int, _ b: Int) -> Int should never throw.

  5. fatalError is the same as throw.” Profoundly not. throw is recoverable; fatalError terminates the process. Use fatalError only for “this is a programmer bug, I want a crash with a clear message” cases — typically in init? 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 NetworkError to 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 LocalizedError and implement errorDescription to get error.localizedDescription for free, ready for Text(error.localizedDescription) in SwiftUI.

WARNING: Do not rethrow errors at module boundaries without thinking. If your domain layer rethrows a URLError to 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