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:
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
AppErrorenum that every layer throws. - Throwing for programmer bugs or crashing for expected failures.
- Catching
errorand 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¶
- Define
enum ValidationError: LocalizedErrorwith at least four cases and use it in avalidate(user:)function. - Write
func retry<T>(times:_:)with exponential backoff usingThread.sleep, and test it with a flaky closure. - 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. - Convert a throwing function to
throws(MyError)and write an exhaustivecatch. - Wrap
FileManagererrors into your ownStorageErrorat a module boundary. - Decide, for each:
Int("x"),FileHandle(forReadingFrom:),array[10],Dictionary[key]— optional, throw, or crash?
Recap¶
- Optionals for ordinary absence,
throwsfor failures with reasons,Resultfor stored outcomes,preconditionfor bugs. - Model errors as per-domain enums with context; translate at boundaries; add
LocalizedError. rethrowsforwards errors from closure arguments; typed throws (throws(E)) gives exact error types.deferguarantees cleanup.
Next: Codable & JSON →