Error Handling
try. Errors are ordinary typed values, so you can match them precisely instead of inspecting error codes.
This lesson covers defining errors, the four ways to handle them, guaranteeing cleanup with defer, and knowing when a thrown error beats a returned optional.
Defining Errors
An error can be any type conforming to Error, but in practice it is almost always an enum: that makes the complete set of failure modes explicit, and the compiler can check that you have covered the ones you care about.
The Error Protocol
Each case names one failure, and associated values carry the context needed to explain it. Adopting LocalizedError adds a human-readable description for logs and dialogs.
// An enum is the idiomatic way to model the ways an operation can fail
enum ValidationError: Error {
case empty
case tooShort(minimum: Int) // an associated value carries context
case containsDigits
}
// LocalizedError adds a description suitable for a user-facing message
extension ValidationError: LocalizedError {
var errorDescription: String? {
switch self {
case .empty: return "The name is empty."
case .tooShort(let min): return "Use at least \(min) characters."
case .containsDigits: return "Digits are not allowed."
}
}
}
print(ValidationError.tooShort(minimum: 3).errorDescription ?? "?")
// prints: Use at least 3 characters.
Throwing Errors
A function that can fail is marked throws, and it signals failure with throw. The rule the compiler enforces is simple: any call to a throwing function must be written with try, so failure can never be ignored by accident.
func validate(_ name: String) throws -> String {
guard name.isEmpty == false else { throw ValidationError.empty }
guard name.count >= 3 else { throw ValidationError.tooShort(minimum: 3) }
guard name.contains(where: \.isNumber) == false else {
throw ValidationError.containsDigits
}
return name
}
// A successful call
do {
print(try validate("Ada")) // prints: Ada
} catch {
print(error)
}
// Catch a specific error type first, then anything unexpected
do {
_ = try validate("")
} catch let error as ValidationError {
print(error) // empty
} catch {
print("unexpected: \(error)") // any other error type
}
Handling Errors
There are exactly four options at a call site: catch the error locally, convert it to nil, assert that it cannot happen, or pass it up the call stack. Choose the one that matches what you can actually do about the failure.
do / catch
A do block contains the throwing calls and one or more catch clauses inspect the error. Clauses are tried in order, so specific patterns come first and a bare catch acts as the catch-all.
enum FileError: Error {
case notFound(String), unreadable
}
func readConfig(name: String) throws -> String {
if name.isEmpty { throw FileError.notFound("(empty)") }
if name == "secret" { throw FileError.unreadable }
return "config: \(name)"
}
func load(_ name: String) {
do {
let content = try readConfig(name: name)
print(content)
} catch FileError.notFound(let name) {
print("missing: \(name)") // binds the associated value
} catch FileError.unreadable {
print("permission denied")
} catch {
print("unexpected: \(error)") // catch-all, must come last
}
}
load("app") // prints: config: app
load("") // prints: missing: (empty)
load("secret") // prints: permission denied
try? and try!
try? turns a thrown error into nil, producing an optional result. That is appropriate when the reason for failure carries no information you would act on. try! crashes on failure and belongs only in code where an error is genuinely impossible.
// try? converts failure into nil, discarding the error itself
print(try? readConfig(name: "app") ?? "none") // prints: config: app
let failed = try? readConfig(name: "secret")
print(failed == nil) // prints: true
// try? flattens an already-optional result: no String?? to unwrap
func find(_ key: String) throws -> String? { key == "x" ? "found" : nil }
print((try? find("x")) ?? "none") // prints: found
// try! asserts success and crashes otherwise
// print(try! readConfig(name: "secret")) // ❌ crash: unreadable
Propagating with throws
When a function cannot do anything useful about a failure, it declares throws and lets the error travel to whoever called it. Marking a function rethrows promises it can only throw what a closure argument threw.
// Propagation: no try-catch needed, the error travels to the caller
func loadAll(_ names: [String]) throws -> [String] {
var results: [String] = []
for name in names {
results.append(try readConfig(name: name))
}
return results
}
do {
print(try loadAll(["app", "app2"])) // ["config: app", "config: app2"]
} catch {
print("failed: \(error)")
}
// rethrows: only the closure can introduce an error
func attempt(_ body: () throws -> String) rethrows -> String? {
try? body()
}
print(attempt { try readConfig(name: "app") } ?? "none") // config: app
Resource Safety
Two features matter when errors meet resources. defer guarantees cleanup no matter how a scope ends, and Result lets a failure travel as an ordinary value instead of an exception.
defer Blocks
A defer block runs when the current scope exits — by return, by throw, or by break. The body executes before control actually leaves, so it is the right place to release a resource. Multiple deferred blocks run in reverse order, like a stack.
func readFile(at path: String) throws -> String {
// defer runs on every exit path: normal return or thrown error
defer { print("closed \(path)") }
print("opened \(path)")
guard path.hasSuffix(".txt") else { throw FileError.notFound(path) }
return "contents of \(path)"
}
print(try? readFile(at: "notes.txt") ?? "none")
// prints: opened notes.txt, closed notes.txt, contents of notes.txt
// The cleanup ran even though this call failed
print(try? readFile(at: "notes.pdf") == nil)
// prints: opened notes.pdf, closed notes.pdf, true
// Several defers unwind in reverse order
func scoped() {
defer { print("third") }
defer { print("second") }
defer { print("first") }
print("body")
}
scoped() // prints: body, first, second, third
The Result Type
Result<Success, Failure> is an enum holding either a value or an error. Use it when the failure cannot be handled at the point where it occurs — for example inside an asynchronous callback that has nobody to throw to.
enum MathError: Error { case divisionByZero }
// Result carries success or failure as a value instead of throwing
func divide(_ a: Int, by b: Int) -> Result<Int, MathError> {
guard b != 0 else { return .failure(.divisionByZero) }
return .success(a / b)
}
// get() rethrows, so try? turns the outcome back into an optional
print(try? divide(10, by: 2).get()) // prints: Optional(5)
print(try? divide(10, by: 0).get()) // prints: nil
// switch over the outcome when both cases need first-class handling
switch divide(9, by: 3) {
case .success(let value): print("ok \(value)") // prints: ok 3
case .failure(let error): print("failed \(error)")
}
// map transforms a success and leaves a failure untouched
print(try? divide(8, by: 2).map { $0 * 2 }.get()) // prints: Optional(8)
Common Pitfalls
Swallowing Errors
An empty catch or a bare try? discards the reason for failure, turning a diagnosable bug into a mysterious silence. Either handle the error or report it with enough context for the next reader to act.
// ❌ An empty catch hides every failure, real bugs included
func loadQuietly(_ name: String) {
do {
_ = try readConfig(name: name)
} catch { } // nothing recorded, nothing reported
}
// ❌ try? can hide a failure the caller genuinely needs to know about
let silent = try? readConfig(name: "secret")
print(silent == nil) // prints: true — but why? nobody knows
// ✅ Report it with enough context to be actionable
func loadReported(_ name: String) -> String {
do {
return try readConfig(name: name)
} catch let error as LocalizedError {
return "failed: \(error.errorDescription ?? "unknown")"
} catch {
return "failed: \(error)"
}
}
print(loadReported("app")) // prints: config: app
print(loadReported("secret")) // prints: failed: unreadable
Catch Order & Specificity
Clauses are matched top to bottom, so a bare catch placed first makes everything below it unreachable. Order them from most specific to least, and use catch is Type when the payload is not needed.
enum NetworkError: Error { case timeout(seconds: Int), offline }
func ping() throws { throw NetworkError.timeout(seconds: 30) }
// ❌ A catch-all first would make every clause below it unreachable:
// do { try ping() } catch { } catch NetworkError.offline { }
// ✅ Specific patterns first, general last
do {
try ping()
} catch NetworkError.timeout(let seconds) {
print("timed out after \(seconds)s") // prints: timed out after 30s
} catch NetworkError.offline {
print("no connection")
} catch {
print("unexpected: \(error)")
}
// `is` tests the type when the associated value is not needed
do {
try ping()
} catch is NetworkError {
print("a network problem") // prints: a network problem
}
Errors are now a designed part of your API rather than an afterthought: callers know exactly which failures to expect and how to respond. Continue with value types and classes in Classes & Structs.