Errors, memory and semantics · core

Error handling

Swift's throws is neither exceptions nor error codes — it is a second return path the compiler tracks.

11 min read13 min practice0/2 exercises4 recall cards

By the end you will be able to

  • Define error types and throw them from functions
  • Handle errors with do/catch, try?, try! and defer, and pick the right one
  • Explain how throws differs from exceptions in Java or C++
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

A function is declared func load() throws -> Data. What happens if you call it as let data = load()?

Defining errors

Any type conforming to Error can be thrown. Enums are the usual choice, because the cases enumerate exactly what can go wrong:

enum LoadError: Error {
    case notFound(path: String)
    case permissionDenied
    case corrupted(reason: String)
}

Throwing and calling

func load(_ path: String) throws -> String {
    guard path.hasPrefix("/") else {
        throw LoadError.notFound(path: path)
    }
    return "contents of \(path)"
}

do {
    let text = try load("/etc/hosts")
    print(text)
} catch LoadError.notFound(let path) {
    print("no file at \(path)")
} catch {
    print("something else: \(error)")
}

A bare catch binds the error to a constant called error automatically.

Swift

The three flavours of try

FormMeaningWhen
trypropagate the error to my callerinside a throws function, or a do block
try?turn the error into nilyou genuinely do not care why it failed
try!crash if it throwsyou can state why this cannot fail
let a = try load(path)              // I will handle or propagate it
let b = try? load(path)             // String?  — nil on any failure
let c = try! load("/etc/hosts")     // trust me

try? is convenient and dangerous in the same way ! is on optionals: it discards the reason. Use it when a failure genuinely has one obvious response, not to make a compile error go away.

How this differs from exceptions

In C++ (and with Java's unchecked exceptions), an exception can be thrown from anywhere and propagate through functions that know nothing about it. That makes any line a potential exit point, and it is why exception-safe code is hard to write. (Java's checked exceptions do appear in method signatures — but even there, nothing marks the call sites.)

Swift's model is narrower on purpose:

  • A function that can fail must say throws in its signature.
  • Every call to one must be marked try — visible at the call site.
  • Errors do not unwind arbitrary stack frames; throws is effectively a second return value the compiler threads through for you.
  • There is no finallydefer does that job, and works everywhere, not just around errors.

The practical result: reading a function, you can see every line that can fail because each one starts with try.

Unchecked exceptions: any line might throw
void process() {
    var data = load();      // might throw
    var parsed = parse(data); // might throw
    save(parsed);           // might throw
}
// Nothing in the body says so.
Swift: only the `try` lines
func process() throws {
    let data = try load()
    let parsed = try parse(data)
    try save(parsed)
}
// Every failure point is marked.

Same three calls. In the Swift version the failure points are impossible to miss, and the signature tells every caller that this function can fail.

defer — cleanup that always runs

func process(_ path: String) throws {
    let handle = open(path)
    defer { close(handle) }        // runs on every exit path

    try validate(handle)           // if this throws, close still happens
    try transform(handle)
}

defer blocks run when the current scope exits — by return, by throw, or by falling off the end — in reverse order of declaration. This is Swift's finally, except it is attached to the resource rather than to a try block, so the acquisition and release sit next to each other.

Swift

Typed throws

Swift 6 lets a function declare exactly which error type it can throw:

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

Callers then get an exhaustive catch — the compiler knows the full set of cases. Use it for small, closed error domains inside a module. For public API that may grow new failure modes, plain throws stays more flexible, since adding a case to a typed-throws error is a source-breaking change for every caller.

Predict, then runA wrong prediction you have committed to is worth more than a right answer you read.

defer runs on the way out, whatever the exit path. What does this print?

enum E: Error { case boom }

func work() throws -> Int {
    defer { print("first") }
    defer { print("second") }
    print("body")
    throw E.boom
}

do {
    print(try work())
} catch {
    print("caught")
}
Check yourself

When is try? the right choice?

Throw and catch

Write the code

Write withdraw(_:from:) that throws .insufficientFunds when the amount exceeds the balance and .invalidAmount when it is not positive. Print one line per attempt.

Solution unlocks after 3 attempts

Clean up with defer

Write the code

Add logging that runs whether or not the parse succeeds, and convert failures to nil at the call site.

Solution unlocks after 3 attempts
Connect itSaved on this device. Never graded.

Errors, optionals and exhaustive switches are all Swift making a failure impossible to ignore. Write a paragraph on what these three have in common — and one place where Swift deliberately lets you opt out of the safety, and why that escape hatch exists.

Checkpoint

You can now:

  • Define error enums and throw them
  • Handle errors with do/catch, try?, try! and pattern-matched catch clauses
  • Use defer for cleanup on every exit path
  • Explain how throws differs from exceptions

Next up: ARC — how Swift frees memory, and the one situation where it needs your help.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.