Error Handling
Error handling is how you respond to and recover from error conditions in
your program. Some operations — reading a file, parsing input, hitting a
network — can't always finish successfully, and Swift gives you
first-class syntax to model that fact, surface it, and deal with it
explicitly. This chapter covers representing errors, throwing and
catching them (including Swift 6's typed throws),
cleanup with defer, and the Result type.
Representing errors
In Swift, errors are represented by values of types that conform to the
Error protocol. This empty protocol simply marks a type as
usable for error handling. Enumerations are an excellent fit because they
model a closed set of related failure cases, and associated values can
carry extra detail:
enum VendingMachineError: Error {
case invalidSelection
case insufficientFunds(coinsNeeded: Int)
case outOfStock
}
Throwing errors
A function that can fail is marked with throws before its
return arrow. Inside it, you raise an error with the throw
statement, which immediately exits the current scope:
struct Item { var price: Int; var count: Int }
class VendingMachine {
var inventory = [
"Candy Bar": Item(price: 12, count: 7),
"Chips": Item(price: 10, count: 4),
]
var coinsDeposited = 0
func vend(itemNamed name: String) throws {
guard let item = inventory[name] else {
throw VendingMachineError.invalidSelection
}
guard item.count > 0 else {
throw VendingMachineError.outOfStock
}
guard item.price <= coinsDeposited else {
throw VendingMachineError.insufficientFunds(
coinsNeeded: item.price - coinsDeposited)
}
coinsDeposited -= item.price
var newItem = item
newItem.count -= 1
inventory[name] = newItem
print("Dispensing \(name)")
}
}
Typed throws
By default throws is untyped — the function may
throw any Error, and callers catch an opaque
any Error. Swift 6 lets you declare exactly which error type
a function throws by writing throws(SomeErrorType). The
compiler then knows the precise type in catch clauses, which
is valuable for libraries with a fixed error set and for embedded or
performance-sensitive code that wants to avoid existential boxing.
func buy(_ name: String, from machine: VendingMachine)
throws(VendingMachineError) {
guard machine.inventory[name] != nil else {
throw .invalidSelection // type is known, so .case works
}
// ...
}
do {
try buy("Pretzels", from: VendingMachine())
} catch {
// `error` is typed as VendingMachineError, not any Error
switch error {
case .invalidSelection: print("No such item")
case .insufficientFunds(let n): print("Need \(n) more")
case .outOfStock: print("Sold out")
}
}
Note: Plain throws is exactly equivalent
to throws(any Error), and a non-throwing function is
equivalent to throws(Never). Reach for typed throws when the
error set is genuinely closed; for most app code, untyped
throws remains the flexible default.
Handling errors with do / catch
You call a throwing function with try, inside a
do block. If it throws, execution jumps to a matching
catch clause. Clauses can pattern-match specific cases; a
final catch with no pattern binds the error to the implicit
error constant.
let machine = VendingMachine()
machine.coinsDeposited = 8
do {
try machine.vend(itemNamed: "Candy Bar")
print("Enjoy your snack!")
} catch VendingMachineError.invalidSelection {
print("Invalid selection.")
} catch VendingMachineError.insufficientFunds(let coinsNeeded) {
print("Insert \(coinsNeeded) more coins.")
} catch VendingMachineError.outOfStock {
print("Out of stock.")
} catch {
print("Unexpected error: \(error)")
}
// → Insert 4 more coins.
Propagating errors
A throwing function doesn't have to handle errors itself — it can let
them propagate out to its caller. Just mark the calling
function throws and use try; any error thrown by
the inner call passes straight up the chain:
let favouriteSnacks = ["Alice": "Chips", "Bob": "Candy Bar"]
func buyFavouriteSnack(person: String, machine: VendingMachine) throws {
let snack = favouriteSnacks[person] ?? "Candy Bar"
try machine.vend(itemNamed: snack) // errors propagate to our caller
}
A throwing initializer propagates errors the same way as a throwing function.
Converting errors to optionals: try? and try!
Sometimes you don't care why an operation failed, only whether
it did. try? converts a thrown error into nil,
giving you an optional result:
func fetchData() throws -> Int { 42 }
let value = try? fetchData() // Int? — nil if it throws
if let v = try? fetchData() {
print("Got \(v)") // → Got 42
}
try! asserts that the call will not throw and unwraps the
result directly. Use it only when you're certain — a thrown error becomes
a runtime crash:
let definitely = try! fetchData() // crashes if fetchData() throws
Watch out: Treat try! like forced
unwrapping — reserve it for situations where a throw is logically
impossible (e.g. loading a resource you ship inside your own app bundle).
Misjudging that turns a recoverable error into a crash.
Specifying cleanup actions with defer
A defer block runs just before the current scope is left —
whether by reaching the end, returning, or throwing an error. It's the
reliable place to undo work: close a file, release a lock, roll back a
change. Multiple defer blocks run in reverse order
of how they appear.
func processFile(at path: String) throws {
let file = open(path)
defer {
close(file) // always runs, even if we throw below
}
while let line = try file.readLine() {
// work with line; any throw still triggers the defer
}
// close(file) runs here automatically
}
Rethrowing
A function that takes a throwing closure and only throws because
of that closure can be marked rethrows instead of
throws. Such a function is treated as non-throwing when
called with a non-throwing argument, sparing callers an unnecessary
try:
func firstMatch<T>(in items: [T],
where predicate: (T) throws -> Bool)
rethrows -> T? {
for item in items where try predicate(item) {
return item
}
return nil
}
// Non-throwing closure → no `try` needed:
let firstEven = firstMatch(in: [1, 3, 4, 7]) { $0 % 2 == 0 }
print(firstEven as Any) // → Optional(4)
The Result type
Sometimes you want to capture success-or-failure as a value you
can store and pass around — for example to deliver the outcome of an
asynchronous callback. The standard library's
Result<Success, Failure> enum does exactly that, with
.success and .failure cases. Its
get() method bridges back into the try/catch
world:
func divide(_ a: Int, by b: Int) -> Result<Int, Error> {
guard b != 0 else {
return .failure(VendingMachineError.invalidSelection)
}
return .success(a / b)
}
let outcome = divide(10, by: 2)
switch outcome {
case .success(let value): print("Result: \(value)") // → Result: 5
case .failure(let error): print("Failed: \(error)")
}
Tip: For ordinary synchronous code, prefer
throws — it's more concise and composes with
try. Reach for Result when a failure needs to be
held as a value, and remember that modern async/await
functions can throw directly, so they rarely need it.
Wrapping up
Swift's error handling makes failure paths explicit and hard to ignore:
model errors as Error types, throw them,
propagate or catch them, narrow them with typed
throws, guarantee cleanup with defer, and
capture outcomes as values with Result. With these tools your
code recovers gracefully instead of crashing. Next we move into
concurrency — running work in parallel safely with
async/await, actors, and Swift 6's strict
concurrency model.