EduFlow

Section 2 · Modelling with Types

Error Handling and Typed Throws

18 min3 min readChoose wisely

Lesson at a glance

Describe failures as error enums, throw and propagate them with try, handle them with do-catch, and decide when Swift 6 typed throws are worth it.

  1. 1Errors are enums
  2. 2throw and try
  3. 3Handling errors with do-catch
  4. 4try? and try!
  5. 5Messages users can read
  6. 6Typed throws in Swift 6

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

What should happen when a user tries to save an expense with no title, a negative amount, or one that blows straight through the month’s food budget? Returning an optional, as you did in the optionals lesson, tells the caller that it failed but not why, and Pocket Budget needs the why to show the right message. Swift’s error handling makes failure part of a function’s signature, with the reasons spelled out as types.

Errors are enums

Start by listing the ways recording an expense can fail:

BudgetError.swift
import Foundation

enum BudgetError: Error, Equatable {
    case emptyTitle
    case nonPositiveAmount
    case overLimit(by: Decimal)
}

Any type that conforms to Error can be thrown, and an enum with associated values is the natural fit: each case is one failure, and overLimit carries how far over the user would go. Error has no requirements; it marks the type as throwable. Equatable will help with tests in the last lesson.

throw and try

A function that can fail says so with throws and stops with throw:

CategoryBudget.swift
struct CategoryBudget {
    var limit: Decimal
    private(set) var spent: Decimal = 0

    mutating func record(title: String, amount: Decimal) throws {
        guard !title.trimmingCharacters(in: .whitespaces).isEmpty else {
            throw BudgetError.emptyTitle
        }
        guard amount > 0 else {
            throw BudgetError.nonPositiveAmount
        }
        let newTotal = spent + amount
        guard newTotal <= limit else {
            throw BudgetError.overLimit(by: newTotal - limit)
        }
        spent = newTotal
    }
}

guard and throw pair perfectly: each guard states a rule, and its else throws the matching error. private(set) lets anyone read spent but only the budget itself change it, so the only way to spend money is through record, which enforces the rules.

Every call to a throwing function must be marked with try. That keyword is for humans: scan a function for try and you see every line where control might leave early.

Handling errors with do-catch

Swift
var food = CategoryBudget(limit: 50)

do {
    try food.record(title: "Dinner", amount: 62)
    print("Saved")
} catch BudgetError.overLimit(let by) {
    print("That's \(by) over your food budget")
} catch {
    print("Couldn't save: \(error)")
}

When record throws, execution jumps straight to the first catch whose pattern matches, skipping print("Saved"). Catch clauses use the same pattern matching as switch, so you can bind associated values. The final bare catch handles everything else and gives you the error as a constant named error.

If a function can’t sensibly handle an error, it doesn’t have to. Mark it throws too, call with try and no do, and the error travels up to its caller.

record throws overLimit. saveExpense is marked throws and passes the error up. The Save button handler has a do-catch that catches it and shows a message. record(...) throw .overLimit saveExpense() throws: passes it up Save button do-catch: shows alert
An error travels up until something catches it

try? and try!

Two shortcuts exist. try? converts the result into an optional: success gives a value, failure gives nil. try! asserts that the call can’t fail and crashes if it does, with the same risk as a force unwrap.

Messages users can read

print(error) shows overLimit(by: 12), which is fine for a log and useless in an alert. Conform to LocalizedError to supply human text:

Swift
extension BudgetError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .emptyTitle: "Give the expense a name."
        case .nonPositiveAmount: "Enter an amount greater than zero."
        case .overLimit(let by): "This puts you \(by.formatted(.currency(code: "USD"))) over budget."
        }
    }
}

Now error.localizedDescription returns that sentence, ready for a SwiftUI alert.

Typed throws in Swift 6

Plain throws means “can throw any Error”. Swift 6 adds typed throws, where the signature names the exact type:

Swift
mutating func record(title: String, amount: Decimal) throws(BudgetError) {
    // same body; inside, `throw .emptyTitle` can drop the type name
}

The payoff is at the call site. When every try in a do block throws BudgetError, the error in catch is a BudgetError, not any Error, so you can switch over it exhaustively with no catch-all. Write do throws(BudgetError) to state that explicitly:

Swift
do throws(BudgetError) {
    try food.record(title: "Snack", amount: 100)
} catch {
    switch error {
    case .emptyTitle: print("Give it a name")
    case .nonPositiveAmount: print("Amount must be positive")
    case .overLimit(let by): print("That's \(by) over budget")
    }
}

The cost is rigidity: once a function promises BudgetError, it can’t start throwing a decoding or network error without changing its signature and every caller. The Swift project’s own guidance is to keep untyped throws as the default and use typed throws inside a module where callers handle every case, as Pocket Budget’s validation does. For networking later in the course you’ll stay with plain throws, because those calls can fail in more ways than you control.

That completes the modelling section. You can now describe Pocket Budget’s data and its failures precisely. Next, you put that data on screen with SwiftUI.

Key takeaways

  • Model the ways an operation can fail as an enum that conforms to Error, with associated values for the details.
  • Every call to a throwing function is marked with try, so readers can see exactly where control might jump out.
  • do-catch handles errors; catch clauses can pattern-match specific cases, and a final catch gets the rest as error.
  • try? turns failure into nil and throws away the reason, so use it only when the reason truly doesn’t matter.
  • Typed throws (throws(BudgetError)) fit code where callers handle every case; untyped throws stays the better default for public APIs.

Further reading

Check your understanding

3 questions

Question 1 of 3

func record(...) throws. What does let result = try? budget.record(title: "Tea", amount: 3) give you when it fails?

Question 2 of 3

A function calls try budget.record(...) but has no do-catch. When does that compile?

Question 3 of 3

When is typed throws, such as throws(BudgetError), the better choice?

Practice

Choose wisely

Follow the errors

This uses the CategoryBudget and BudgetError from the lesson: record throws .emptyTitle for a blank title, .nonPositiveAmount for an amount of zero or less, and .overLimit(by:) when the new total would pass the limit. Predict everything the loop prints.

Swift
var food = CategoryBudget(limit: 50)
let attempts: [(String, Decimal)] = [("Lunch", 12), ("", 5), ("Dinner", 45)]

for (title, amount) in attempts {
    do {
        try food.record(title: title, amount: amount)
        print("Saved \(title)")
    } catch BudgetError.overLimit(let by) {
        print("Over by \(by)")
    } catch {
        print("Rejected: \(error)")
    }
}
print(food.spent)
Follow the errors