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.
- 1Errors are enums
- 2throw and try
- 3Handling errors with do-catch
- 4try? and try!
- 5Messages users can read
- 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:
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:
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
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.
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:
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:
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:
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-catchhandles errors;catchclauses can pattern-match specific cases, and a finalcatchgets the rest aserror.try?turns failure intoniland 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; untypedthrowsstays the better default for public APIs.