Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

9. Errors

Swift asks you to sort failures into three kinds and gives each a different mechanism. Getting the sorting right is most of the work; the syntax takes ten minutes.

A value that might not be there is an Optional. There is no error, nothing went wrong, and a dictionary with no entry for a key is behaving correctly. Chapter 5 covered these.

A thing that can fail for reasons outside your control is an error: the file is missing, the network is down, the input is malformed. The caller can reasonably do something about it, so the function throws and the caller decides.

A thing that cannot fail unless the program is wrong is a bug. An index past the end of an array, an arithmetic overflow, a force unwrap of nothing. These stop the program, and they should, because continuing means running code whose assumptions have already been proven false.

Do not use the second mechanism for the third. A function that throws invalidIndex is asking every caller to handle a case that means somebody upstream made a mistake, and none of them can fix it.

9.1 Throwing and Catching

An error is any value whose type conforms to Error, which is an empty protocol. An enum is the usual choice, because a function’s failures are a fixed set of cases with values attached, which is the shape Chapter 8 is about.

// An error is a value of a type you define, thrown and caught by pattern.
enum ConfigError: Error {
    case missingKey(String)
    case notANumber(key: String, value: String)
    case outOfRange(key: String, value: Int, allowed: ClosedRange<Int>)
}

let settings = ["port": "8080", "retries": "many", "timeout": "30"]

func intValue(for key: String, in range: ClosedRange<Int>) throws -> Int {
    guard let text = settings[key] else {
        throw ConfigError.missingKey(key)
    }
    guard let n = Int(text) else {
        throw ConfigError.notANumber(key: key, value: text)
    }
    guard range.contains(n) else {
        throw ConfigError.outOfRange(key: key, value: n, allowed: range)
    }
    return n
}

func report(_ key: String) -> String {
    do {
        return "\(key) = \(try intValue(for: key, in: 1...600))"
    } catch ConfigError.missingKey(let key) {
        return "\(key) is not set"
    } catch ConfigError.notANumber(let key, let value) {
        return "\(key) should be a number, not \"\(value)\""
    } catch ConfigError.outOfRange(let key, let value, let allowed) {
        return "\(key) is \(value), outside \(allowed.lowerBound)...\(allowed.upperBound)"
    } catch {
        return "\(key) failed: \(error)"
    }
}

for key in ["port", "retries", "timeout", "host"] {
    print(report(key))
}

// try? turns a thrown error into nothing, when you do not care which it was.
let port = (try? intValue(for: "port", in: 1...99999)) ?? 80
print("port is \(port)")
port is 8080, outside 1...600
retries should be a number, not "many"
timeout = 30
host is not set
port is 8080

throws goes before the arrow and is part of the function’s type, so it is visible at every call. A call to a throwing function must be marked try, and the compiler insists:

missing_try.swift:7:9: error: call can throw but is not marked with 'try'
7 | let n = risky()
  |         |- note: did you mean to use 'try'?
  |         |- note: did you mean to handle error as optional value?
  |         `- note: did you mean to disable error propagation?

The three notes are the three answers: try and let it propagate, try? and treat failure as nothing, try! and crash if it happens. try is not a block and does not catch anything; it marks the call so a reader scanning the function can see every place control might leave.

catch clauses are patterns, the same patterns as a switch arm, so a thrown enum can be taken apart with its associated values in the catch itself. A bare catch matches anything and binds the error to a name called error without your declaring it. Catches must be exhaustive, which means a bare catch at the end unless the enclosing function itself throws, in which case anything unhandled propagates.

Propagation is the default and the reason this design is worth having. A function that calls three throwing functions and handles none of them writes try three times and says throws once, and the error travels to whoever is in a position to do something about it. Compare a return code, which every layer in between must check and forward by hand, and which any layer may quietly drop.

9.2 defer

defer schedules a block to run when the current scope exits, however it exits: a return, a throw, a break, or falling off the end.

// defer runs on the way out, however the way out happens.
enum Trouble: Error { case badLine(Int) }

func process(_ lines: [String]) -> String {
    print("open")
    defer { print("close") }

    do {
        for (i, line) in lines.enumerated() {
            defer { print("  done with line \(i)") }
            print("  line \(i): \(line)")
            if line.isEmpty {
                throw Trouble.badLine(i)
            }
        }
        return "all \(lines.count) lines"
    } catch {
        return "stopped: \(error)"
    }
}

print(process(["a", "b"]))
print("---")
print(process(["a", "", "c"]))
open
  line 0: a
  done with line 0
  line 1: b
  done with line 1
close
all 2 lines
---
open
  line 0: a
  done with line 0
  line 1: 
  done with line 1
close
stopped: badLine(1)

The cleanup sits next to the thing it cleans up rather than at the bottom of the function in every exit path, which is the point. The second run shows it working on the way out of a throw: the line’s defer still runs, and so does the function’s.

Two rules. Several defers in one scope run in reverse order, last registered first, which is right when the second depends on the first. And the scope is the enclosing braces, not the function, so the defer inside the loop body runs on every turn.

defer is for releasing something you acquired: closing a file, unlocking a lock, restoring a setting. It is not a place to put the second half of a function.

9.3 Result

Result<Success, Failure> is an error stored as a value rather than thrown. It is what you want when the failure has to be carried somewhere before anybody looks at it: across a callback, into an array, out of a batch of operations where each one may fail separately.

// Result is the error as a value, for when you cannot throw at the call.
enum Trouble: Error, CustomStringConvertible {
    case tooShort(String)
    var description: String {
        switch self {
        case .tooShort(let s): return "\"\(s)\" is too short"
        }
    }
}

func check(_ s: String) throws -> String {
    guard s.count >= 3 else { throw Trouble.tooShort(s) }
    return s.uppercased()
}

let outcomes = ["ada", "x", "grace"].map { name in
    Result { try check(name) }
}

for outcome in outcomes {
    switch outcome {
    case .success(let s): print("ok", s)
    case .failure(let e): print("no", e)
    }
}

// A Result can be transformed without being opened.
let lengths = outcomes.map { $0.map(\.count) }
print(lengths.map { (try? $0.get()) ?? -1 })

// Typed throws names the one error a function can produce.
func strict(_ s: String) throws(Trouble) -> String {
    guard s.count >= 3 else { throw .tooShort(s) }
    return s
}

do {
    print(try strict("hi"))
} catch {
    // error is a Trouble here, not any Error
    print("caught a \(type(of: error)): \(error)")
}
ok ADA
no "x" is too short
ok GRACE
[3, -1, 5]
caught a Trouble: "hi" is too short

Result { try something() } runs the closure and captures whichever way it went, which is the bridge from throwing code into stored results. get() is the bridge back: it returns the value or throws the error, so try result.get() puts you back in the throwing world.

Result is an enum with two cases, so it switches like one, and map transforms the success while leaving a failure alone, the way Optional.map does. \.count is a key path, which is a lightweight way of writing { $0.count }.

Conforming the error type to CustomStringConvertible is worth doing whenever a human will read the message. Without it, printing an error gives you the case name and its payload, which is fine for a log and poor in a message to a user.

throws(Trouble) is typed throws: the function declares the one error type it can produce, so the catch gets a Trouble rather than an Error and can switch over its cases exhaustively without a default. Use it where the set of failures is genuinely closed and part of the contract. Plain throws means throws(any Error) and stays the right default for anything that calls into code you do not control.

9.4 Stopping

When the program is wrong, stop it. fatalError("message") always does. precondition(condition, "message") checks a condition that must hold, in release builds as well as debug. assert checks one only in debug builds, for things too expensive to verify in production.

The choice between precondition and assert is about what a wrong answer costs against what the check costs. Validating an index is cheap and belongs in both; verifying that a list of a million items is sorted is not.

A function that can never return is marked -> Never, which tells the compiler that a guard else calling it is a legitimate exit.

Exercise 9-1. Give ConfigError a CustomStringConvertible conformance and delete the pattern-matching catch clauses, keeping only the bare one. Decide which version you would rather maintain.

Exercise 9-2. Write a function that reads a file of numbers and returns [Result<Int, Trouble>], one entry per line, then a second function that turns that into either all the numbers or the first error.

Exercise 9-3. Add a defer to intValue(for:in:) that logs the key on the way out, and confirm it fires on the throwing paths.

Exercise 9-4. Find out what rethrows means and write a function that needs it.