Error handling: replace error codes with exceptions
Let failures reach the code that can handle them, without every caller checking.
Watch the narrated video
Every step
Problem
Steps 1–4Refactor
Steps 5–7Payoff
Steps 8–9Swift bonus
Steps 10–11Recap
Steps 12–13The code: before and after
Before
class Wallet {
static let ok = 0
static let noAccount = -1
static let lowBalance = -2
var balances: [String: Int] = [:]
func withdraw(_ amount: Int, from id: String) -> Int {
guard let balance = balances[id] else {
return Wallet.noAccount
}
if balance < amount { return Wallet.lowBalance }
balances[id] = balance - amount
return Wallet.ok
}
func transfer(_ amount: Int, from: String, to: String) -> Int {
if balances[to] == nil { return Wallet.noAccount }
let code = withdraw(amount, from: from)
if code != Wallet.ok { return code }
balances[to, default: 0] += amount
return Wallet.ok
}
}
struct TransferHandler {
let wallet: Wallet
func handle(_ amount: Int, from: String, to: String) -> String {
let code = wallet.transfer(amount, from: from, to: to)
if code == Wallet.noAccount { return "404 no such account" }
if code == Wallet.lowBalance { return "409 balance too low" }
return "200 ok"
}
}After
enum WalletError: Error {
case accountNotFound(String)
case insufficientFunds(String)
case accountFrozen(String)
}
class Wallet {
var balances: [String: Int] = [:]
var frozen: Set<String> = []
func freeze(_ id: String) { frozen.insert(id) }
func withdraw(_ amount: Int, from id: String) throws(WalletError) {
guard let balance = balances[id] else {
throw WalletError.accountNotFound(id)
}
if frozen.contains(id) { throw WalletError.accountFrozen(id) }
if balance < amount { throw WalletError.insufficientFunds(id) }
balances[id] = balance - amount
}
func transfer(
_ amount: Int, from: String, to: String
) throws(WalletError) {
if balances[to] == nil { throw WalletError.accountNotFound(to) }
try withdraw(amount, from: from)
balances[to, default: 0] += amount
}
}
struct TransferHandler {
let wallet: Wallet
func handle(_ amount: Int, from: String, to: String) -> String {
do throws(WalletError) {
try wallet.transfer(amount, from: from, to: to)
return "200 ok"
} catch {
switch error {
case .accountNotFound: return "404 no such account"
case .insufficientFunds: return "409 balance too low"
case .accountFrozen: return "423 account frozen"
}
}
}
}Transcript, step by step
Problem
Step 1: A wallet that returns numbers Before
This wallet keeps balances in whole euros, and it reports trouble with three numbers, OK, no account and low balance. Withdraw returns minus one for an unknown account, minus two when the balance is too low, and zero when the money left. Transfer checks the receiver, calls withdraw, and passes any error code it gets straight back up. The handler is the one caller, and its if chain turns each code into the HTTP-style status text the handler returns, so a missing account becomes four oh four.
Step 2: Freeze accounts Code smell
Compliance now freezes accounts, and a frozen account can't send money. So we add a fourth code, minus three, a set of frozen ids, and a check in withdraw, right after the guard, that returns it. The handler didn't change, and it compiles fine.
Step 3: Every caller has to check Code smell
An error code is a failure returned as an ordinary number, so every caller up the chain has to remember to check it, and nothing makes them. Withdraw returns the new code here, transfer checks it and passes it up here, the handler compares it against each code it knows here, and anything else falls through to here. So right now, a transfer from a frozen account moves no money, and the handler answers two hundred OK, because nobody added an if for minus three. Each caller in the chain has to write the line that passes the error code up, and nothing forces it to, while the last line treats any number the handler doesn't know as success, so one missing check turns a failure into two hundred OK.
Step 4: Back it out, refactor first Plan
Let's back the freeze out. We'll refactor first, turning each error code into an exception one at a time, and then the freeze goes back in.
Refactor
Step 5: Missing account throws Replace Error Code with Exception
We start with the missing account, and Replace Error Code with Exception gives it a case in a new enum, Wallet Error. Swift's exceptions are thrown values of any type that conforms to Error, so an enum with one case per failure is the usual shape. Withdraw throws it here and transfer throws it here, each with the account id, where they used to return minus one. Swift marks every function that can throw with throws and every call that can throw with try, so you can still see where a failure may come from. The handler wraps its call in a do, and the catch gives back the same four oh four status text, while handle itself is marked throws, so a failure it doesn't catch still goes on to its caller.
Step 6: Low balance throws Replace Error Code with Exception
Low balance gets the same move, with a second case, insufficient funds. Withdraw throws it when the balance is too small, and the handler gets a second catch that gives back the same four oh nine status text. That leaves withdraw returning OK and nothing else, so the check in transfer can never fire.
Step 7: No more Int return Change Function Declaration
Change Function Declaration drops the Int return type from withdraw and transfer, since there's no number left worth returning. The OK constant, every return OK, and the line that passed the error code up all go, so transfer is down to three lines. It checks the receiver, takes the money from the sender, and pays the receiver. In the handler, the do holds the transfer call and then two hundred OK, which is the answer only if transfer came back normally.
Payoff
Step 8: The freeze is one throw, one catch Payoff
Now the freeze goes in again, and it starts with a third case, account frozen. The wallet gets the frozen set and the freeze method, and withdraw throws the new case here, after the guard and before the balance check. The handler gets one more catch, and it answers with four twenty three, account frozen. Transfer didn't change at all, because it has no line that carries a failure up.
Step 9: Why it was easy Payoff
Back at the same four places, withdraw throws here, and transfer has only a try here, with no line that passes a failure up, so no caller in between can forget one. The handler has one catch here for each failure it reports, and two hundred OK is the answer here only when transfer returned normally. A forgotten if used to answer two hundred OK in silence, but forget a catch now and the error leaves handle and goes to its caller, so it never reaches that line. This fits here because the failure starts deep in withdraw and only the handler knows what to say about it, so transfer shouldn't have to carry it.
Swift bonus
Step 10: Three catches, nothing checks the list Swift bonus
The handler answers every wallet case, yet handle is still marked throws, because a plain throws can carry any error at all. So nothing tells you when a fourth case shows up without a catch, and Swift six's typed throws can name the one error type a function throws.
Step 11: Typed throws, one switch Swift bonus
Withdraw and transfer now say throws Wallet Error, so nothing else can come out of them. The handler's do names the same type, so its one catch gets a Wallet Error, and a switch picks the status text for each case. Add a fourth case and forget it here, and the switch stops compiling, so handle can drop its throws. Typed throws fit a closed set like this one, because a function that also calls a library throwing other errors still needs a plain throws.
Recap
Step 12: Where we started, where we ended Before / after
On the left, withdraw and transfer return numbers, and transfer has to pass each one up by hand. The handler's if chain ends in two hundred OK, so any code it doesn't know counts as success. On the right, withdraw throws a Wallet Error case, and transfer has nothing left to relay. And the handler's do reaches two hundred OK only when the transfer worked, with a catch for each failure.
Step 13: When to reach for it Takeaways
Reach for it when a failure starts deep in a call chain and only a caller near the top knows how to answer it. Skip it when the direct caller answers the failure, like a lookup that often finds nothing, because with no chain to skip, an optional and an if let do the same job. You'll spot it by constants like minus one, by a line that passes a code up the chain, or by a last line that treats unknown codes as success. Swift's own JSON Decoder works this way, because decode throws a Decoding Error when the data doesn't fit the type.