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.