Home

Errors

Throwing, trapping, and choosing between them

Swift has four distinct mechanisms for things going wrong, and using the wrong one is how a recoverable condition turns into a crash report.

Mechanism For
throws Expected failures the caller can handle: bad input, missing file, network refused
Optional Absence that carries no explanation: no such key, no first element
precondition / fatalError Programmer error: an invariant your code guarantees was violated
assert Same, but debug builds only

The line worth drawing: if the failure is caused by the world, throw. If it is caused by the programmer, trap. A parser given malformed input should throw; a parser that finds its own internal offset out of range should trap, because there is no correct recovery from a broken invariant.

Errors are values

Error has no requirements. Any type can be one, and enums are the natural fit:

enum LoadError: Error {
    case notFound(path: String)
    case corrupted(offset: Int)
    case tooLarge(bytes: Int, limit: Int)
}

Attach the context at the throw site, where it is still known. An error that says .corrupted and nothing else forces every layer above to guess.

For anything a user might see, add LocalizedError:

extension LoadError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .notFound(let path): "No file at \(path)."
        case .corrupted(let offset): "The file is damaged at byte \(offset)."
        case .tooLarge(let bytes, let limit): "\(bytes) bytes exceeds the \(limit) byte limit."
        }
    }
}

Typed throws

Until recently every throws meant “throws something, good luck”. Swift 6 lets you name it:

func load(_ path: String) throws(LoadError) -> Data

Now the compiler knows the full set of failures, catch can be exhaustive without a default, and the error can be a frozen enum in an embedded context with no existential box at all.

Use it where the error set is genuinely closed and part of the contract — a parser, a decoder, a small library. Avoid it on API boundaries that will grow: adding a case to a typed-throws function is a source-breaking change for every exhaustive catch, which is the same trap as a non-frozen enum. Untyped throws is still the right default for application code.

rethrows

rethrows says “I only throw if the closure you gave me throws”:

extension Sequence {
    func count(where predicate: (Element) throws -> Bool) rethrows -> Int {
        var total = 0
        for element in self where try predicate(element) { total += 1 }
        return total
    }
}

Callers passing a non-throwing closure do not need try. Without rethrows, every caller of every higher-order function would be forced into error handling it does not need.

defer

defer runs on the way out of scope, however that happens — return, throw, or falling off the end:

func process(_ path: String) throws -> Report {
    let handle = try FileHandle(forReadingFrom: URL(filePath: path))
    defer { try? handle.close() }

    let header = try readHeader(handle)
    guard header.isSupported else { throw LoadError.corrupted(offset: 0) }
    return try buildReport(from: handle, header: header)
}

The close is written next to the open, and there is exactly one of it no matter how many exits the function grows later. Multiple defer blocks run in reverse order, which matches how resources nest.

Warning

defer captures values as they are at the moment the block runs, not when it is written. A defer { print(count) } at the top of a function prints the final count, not zero. This is almost always what you want and occasionally very confusing.

Result, and when to reach for it

Result<Success, Failure> turns an error into a value you can store, pass around, or hand to a completion handler. With async/await covering most callback APIs, its remaining uses are narrow: caching a failure, collecting outcomes from a batch, or crossing an API that cannot be async.

let outcomes: [Result<Data, LoadError>] = paths.map { path in
    Result { try load(path) }.mapError { $0 as! LoadError }
}

If you find yourself converting Result back to throws immediately with try result.get(), the function probably wanted to be throws in the first place.