I'll Be Backend
Principles · Rust

Error handling: replace error codes with exceptions

Let failures reach the code that handles them, without every caller checking.

12 steps4 chapters~5 min at 1.25×#error handling#result#error codes#rust errors
Language
The lesson plays right here. Pause anywhere, step back, or jump to a step below. Watch the Rust video on YouTube Code on GitHub

Watch the narrated video

Every step

Problem

Steps 1–4

Refactor

Steps 5–7

Payoff

Steps 8–10

Recap

Steps 11–12

The code: before and after

Before

const OK: i32 = 0;
const NO_ACCOUNT: i32 = -1;
const LOW_BALANCE: i32 = -2;

struct Wallet {
    balances: HashMap<String, i32>,
}

impl Wallet {
    fn withdraw(&mut self, id: &str, amount: i32) -> i32 {
        let Some(balance) = self.balances.get_mut(id) else {
            return NO_ACCOUNT;
        };
        if *balance < amount {
            return LOW_BALANCE;
        }
        *balance -= amount;
        OK
    }

    fn transfer(&mut self, from: &str, to: &str, amount: i32) -> i32 {
        if !self.balances.contains_key(to) {
            return NO_ACCOUNT;
        }
        let code = self.withdraw(from, amount);
        if code != OK {
            return code;
        }
        self.balances.entry(to.to_string()).and_modify(|b| *b += amount);
        OK
    }
}

fn handle(wallet: &mut Wallet, from: &str, to: &str, amount: i32) -> String {
    let code = wallet.transfer(from, to, amount);
    if code == NO_ACCOUNT {
        return "404 no such account".to_string();
    }
    if code == LOW_BALANCE {
        return "409 balance too low".to_string();
    }
    "200 ok".to_string()
}

After

enum WalletError {
    NoAccount,
    LowBalance,
    Frozen,
}

type Result<T> = std::result::Result<T, WalletError>;

struct Wallet {
    balances: HashMap<String, i32>,
    frozen: HashSet<String>,
}

impl Wallet {
    fn freeze(&mut self, id: &str) {
        self.frozen.insert(id.to_string());
    }

    fn withdraw(&mut self, id: &str, amount: i32) -> Result<()> {
        let Some(balance) = self.balances.get_mut(id) else {
            return Err(WalletError::NoAccount);
        };
        if self.frozen.contains(id) {
            return Err(WalletError::Frozen);
        }
        if *balance < amount {
            return Err(WalletError::LowBalance);
        }
        *balance -= amount;
        Ok(())
    }

    fn transfer(&mut self, from: &str, to: &str, amount: i32) -> Result<()> {
        if !self.balances.contains_key(to) {
            return Err(WalletError::NoAccount);
        }
        self.withdraw(from, amount)?;
        self.balances.entry(to.to_string()).and_modify(|b| *b += amount);
        Ok(())
    }
}

fn handle(wallet: &mut Wallet, from: &str, to: &str, amount: i32) -> String {
    match wallet.transfer(from, to, amount) {
        Err(WalletError::NoAccount) => "404 no such account".to_string(),
        Err(WalletError::LowBalance) => "409 balance too low".to_string(),
        Err(WalletError::Frozen) => "423 account frozen".to_string(),
        Ok(()) => "200 ok".to_string(),
    }
}

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 once the money has left. Transfer checks the receiver, calls withdraw, and passes any error code it gets straight back up. The handle function is the one caller, and each code ends up as 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 lookup, that returns it. The handler didn't change, and the whole file still compiles.

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 with 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 a branch for minus three. Each caller in the chain has to write the line that passes the error code up, and the handler treats any number it doesn't know as success, so one missing branch 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 the error codes into a Rust Result, and then the freeze goes back in.

Refactor

Step 5: No account becomes an Err Replace Error Code with Exception

Rust has no exceptions, so its form of Replace Error Code with Exception returns a Result, Ok with a value or Err with an error, and each caller handles it or passes it on. The no account code becomes the first variant of a Wallet Error enum, and a Result alias pairs that enum with any success value, the way io Result does in the standard library. Withdraw and transfer return Err with no account here and here, and for now low balance still rides inside Ok as a number. The question mark after withdraw hands any Err straight to transfer's caller, so that one character passes the failure up. The handler now matches on the result, so four oh four comes from the Err arm, and the other two status texts still come from Ok.

Step 6: Low balance becomes an Err Replace Error Code with Exception

Low balance goes the same way, as a second variant of the enum. Withdraw returns Err with low balance here, and the minus two constant is gone. The handler gets an Err arm for it, so every failure now arrives as an Err, and Ok only ever carries zero.

Step 7: Nothing left to return Change Function Declaration

Withdraw now only ever returns Ok with zero, so the number tells nobody anything, and Change Function Declaration makes both methods return a Result of unit. The OK constant goes, and transfer loses the two lines that compared the code and passed it up. Transfer now reads in three lines, check the receiver, withdraw with a question mark, and deposit. In the handler, the success arm matches Ok of unit and returns two hundred OK.

Payoff

Step 8: The compiler asks for the arm Payoff

Now the freeze goes in again, and it starts as a third variant, Frozen. The wallet gets the frozen set and the freeze method, and withdraw returns Err with Frozen here, after the lookup and before the balance check. This time the build stops at the handler, because a match has to cover every variant, and this one has no arm for Frozen.

Step 9: One variant, one arm Payoff

The handler gets one more arm, and it answers four twenty three, account frozen. Transfer didn't change at all, because its question mark passes any Err up without knowing which one it is.

Step 10: Why it was easy Payoff

Back at the same four places, withdraw returns the new error here, and transfer passes it up here with one question mark, so a new failure needs no edit there. The handler has one arm here for each failure it reports, and two hundred OK runs here only when transfer returned Ok. A forgotten branch used to answer two hundred OK in silence, and now a forgotten arm doesn't compile, so a new variant can't slip through as success. This fits here because the failure starts deep in withdraw and only the handler knows what to say about it, so transfer only has to pass it along.

Recap

Step 11: Where we started, where we ended Before / after

On the left, withdraw and transfer return numbers, and transfer compares each one with OK before passing it up. The handler's if chain ends in two hundred OK, so any code it doesn't know counts as success. On the right, each failure is a variant of the error enum, and transfer passes it up with one question mark. So the handler's match needs an arm for every variant, and two hundred OK comes only from the Ok arm.

Step 12: 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 handles the miss, like a map lookup that often finds nothing, because with no chain to cross, an Option does 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 branch that treats unknown codes as success. Rust's own parse on a string works this way, because it returns a Result, and a bad number comes back as an Err that a question mark can pass up.