Skip to content

8. Error handling in depth

What & why

You met throws, try, and catch in Beginner. This lesson covers the design side: modelling errors well, Result, rethrows, typed throws (Swift 6), and choosing between optionals, errors, and crashes.

Choosing a failure strategy

Situation Use
Absence is normal and needs no explanation (dict[key], first) Return an optional
Operation can fail for reasons the caller may want to handle or report throws
You must store/pass an outcome (callbacks, batches, async boundaries) Result
Programmer error / violated precondition — the program is wrong precondition, assert, fatalError

Never throw for programmer errors (index out of range) and never crash for expected failures (file not found).

Designing error types

enum NetworkError: Error {
    case invalidURL(String)
    case timeout(after: Double)
    case badStatus(code: Int)
    case decoding(underlying: Error)
}
  • One enum per domain (networking, parsing, storage), not one giant app-wide enum.
  • Associated values carry the context a handler needs.
  • Wrap lower-level errors (underlying: Error) instead of discarding them.
  • Add friendly text via LocalizedError:
import Foundation

extension NetworkError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .invalidURL(let s):       "Invalid URL: \(s)"
        case .timeout(let seconds):    "Timed out after \(seconds)s"
        case .badStatus(let code):     "Server responded with \(code)"
        case .decoding(let e):         "Could not decode response: \(e.localizedDescription)"
        }
    }
}

Catching precisely

func fetch(_ urlString: String) throws -> Data { throw NetworkError.timeout(after: 5) }

do {
    _ = try fetch("https://example.com")
} catch NetworkError.timeout(let seconds) {
    print("retry after backoff (\(seconds)s)")
} catch NetworkError.badStatus(let code) where code >= 500 {
    print("server problem")
} catch let error as NetworkError {
    print("network: \(error.localizedDescription)")
} catch {
    print("unexpected: \(error)")
}

Patterns work like switch patterns: specific cases, where clauses, and as casts, finishing with a catch-all.

Rethrowing and translating

func loadConfig(at path: String) throws -> [String: String] {
    do {
        let text = try String(contentsOfFile: path, encoding: .utf8)
        return try parse(text)
    } catch let error as ConfigError {
        throw error                                // already our type
    } catch {
        throw ConfigError.unreadable(path: path, underlying: error)   // translate
    }
}

enum ConfigError: Error {
    case unreadable(path: String, underlying: Error)
    case syntax(line: Int)
}
func parse(_ text: String) throws -> [String: String] { [:] }

Translate errors at module boundaries so callers only see your domain's errors.

rethrows

A function marked rethrows throws only if the closure you pass to it throws, so callers write try only when they need to:

func apply<T>(_ value: T, _ transform: (T) throws -> T) rethrows -> T {
    try transform(value)
}

struct Rejected: Error {}

let a = apply(2) { $0 * 10 }                      // closure doesn't throw: no `try` needed
let b = try apply("x") { s in                     // closure throws: `try` required
    guard s != "x" else { throw Rejected() }
    return s
}

A rethrows function can only throw errors that come from its closure parameters; it can't invent its own. The standard library uses it in map, filter, and reduce, which is why [1, 2].map { $0 * 2 } needs no try but try strings.map { try parse($0) } does.

For retry logic, where the function itself throws, use plain throws:

func retry<T>(times: Int, _ body: () throws -> T) throws -> T {
    precondition(times > 0)
    var lastError: Error = CancellationError()
    for _ in 0..<times {
        do { return try body() } catch { lastError = error }
    }
    throw lastError
}

Result

enum ParseFailure: Error { case notANumber(String), negative(Int) }

func parse(_ s: String) -> Result<Int, ParseFailure> {
    guard let n = Int(s) else { return .failure(.notANumber(s)) }
    guard n >= 0 else { return .failure(.negative(n)) }
    return .success(n)
}

switch parse("42") {
case .success(let n): print(n)
case .failure(let e): print(e)
}

// Transform and chain
let doubled = parse("21").map { $0 * 2 }                          // .success(42)
let chained = parse("5").flatMap { n in parse(String(n - 10)) }   // .failure(.negative(-5))
let value = try parse("7").get()                                   // convert back to throws

Wrap a throwing call: Result { try riskyThing() }.

Collect all outcomes instead of stopping at the first error:

let results = ["1", "x", "-3"].map(parse)
let good = results.compactMap { try? $0.get() }       // [1]

Typed throws (Swift 6)

Plain throws means "throws any Error". Swift 6 lets you specify the exact error type:

func parseAge(_ text: String) throws(ParseFailure) -> Int {
    guard let n = Int(text) else { throw .notANumber(text) }
    guard n >= 0 else { throw .negative(n) }
    return n
}

do {
    let age = try parseAge("-4")
    print(age)
} catch {
    // `error` is ParseFailure here, not `any Error` — the switch below is exhaustive
    switch error {
    case .notANumber(let s): print("bad: \(s)")
    case .negative(let n):   print("negative: \(n)")
    }
}

Benefits: exhaustive handling, no existential boxing (important in embedded/low-level code), and Result<Int, ParseFailure> and throws(ParseFailure) interconvert cleanly. Costs: more rigid APIs — changing the error type later is a breaking change. Guidance: keep untyped throws for most library and application code; reach for typed throws inside a single module, in generic code that forwards errors (throws(E)), and in performance-critical paths.

throws(Never) is "doesn't throw" — it's how rethrows-like behaviour generalizes.

Cleanup and defer

func process(_ path: String) throws {
    let handle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path))
    defer { try? handle.close() }
    // use handle; it's closed no matter how we leave
}

Asserting programmer errors

func percent(of part: Int, in total: Int) -> Double {
    precondition(total != 0, "total must be non-zero")
    return Double(part) / Double(total) * 100
}

assert(percent(of: 1, in: 2) == 50)        // debug builds only
  • assert — checked in debug builds only (dev-time sanity checks).
  • precondition — checked in debug and release (contract violations).
  • fatalError("unreachable") — always stops; use for truly impossible paths.

Common mistakes

  • Using try? to avoid thinking about errors and losing diagnostics.
  • One giant AppError enum that every layer throws.
  • Throwing for programmer bugs or crashing for expected failures.
  • Catching error and doing nothing — at minimum, log it.
  • Leaking implementation errors (e.g. DecodingError, NSError) through your public API.
  • Typed throws everywhere — it can make evolution painful.

Exercises

  1. Define enum ValidationError: LocalizedError with at least four cases and use it in a validate(user:) function.
  2. Write func retry<T>(times:_:) with exponential backoff using Thread.sleep, and test it with a flaky closure.
  3. Write a function that returns Result<[Int], ParseFailure> for an entire array of strings, stopping at the first failure; then another that collects all failures.
  4. Convert a throwing function to throws(MyError) and write an exhaustive catch.
  5. Wrap FileManager errors into your own StorageError at a module boundary.
  6. Decide, for each: Int("x"), FileHandle(forReadingFrom:), array[10], Dictionary[key] — optional, throw, or crash?

Recap

  • Optionals for ordinary absence, throws for failures with reasons, Result for stored outcomes, precondition for bugs.
  • Model errors as per-domain enums with context; translate at boundaries; add LocalizedError.
  • rethrows forwards errors from closure arguments; typed throws (throws(E)) gives exact error types.
  • defer guarantees cleanup.

Next: Codable & JSON →