Replace Error Code with Exception
Intent
Section titled “Intent”An error code is a special return value — -1, null, false, an empty string — that a function uses to say “that didn’t work.” The problem is that it looks exactly like a normal value, so the caller has to remember to check for it. Forget once and the sentinel flows downstream as if it were real data.
Replace Error Code with Exception separates the two channels. The return value carries only successful results; failure travels a different road that cannot be silently mistaken for success. In languages with exceptions (TypeScript, Python) you throw/raise. In Go and Rust there are no exceptions for ordinary failures — the idiomatic move is to make the error explicit in the type: return an error alongside the value in Go, or a Result in Rust. We show both, and say which is which.
The smell
Section titled “The smell”A function returns -1 for “not found”, null for “couldn’t parse”, or false for “didn’t save”. Every caller is now obligated to write a check that is trivial to omit, and the omission is invisible until the bad value detonates somewhere far away. Worse, the same sentinel often means different things in different functions — -1 is a missing index here and an error count there — so readers cannot trust it at a glance. Failure has been smuggled into the value channel, where it does not belong.
Before → After
Section titled “Before → After”Withdrawing from an account. Before, the function returns -1 when there are insufficient funds. After, it signals failure on its own channel — an exception in TS/Python, an explicit error/Result in Go/Rust.
// Before — caller must remember that -1 means failurefunction withdraw(balance: number, amount: number): number { if (amount > balance) { return -1; } return balance - amount;}
// After — failure cannot be confused with a balanceclass InsufficientFundsError extends Error {}
function withdraw(balance: number, amount: number): number { if (amount > balance) { throw new InsufficientFundsError(`need ${amount}, have ${balance}`); } return balance - amount;}# Before — caller must remember that -1 means failuredef withdraw(balance, amount): if amount > balance: return -1 return balance - amount
# After — raising makes failure impossible to ignoreclass InsufficientFundsError(Exception): pass
def withdraw(balance, amount): if amount > balance: raise InsufficientFundsError(f"need {amount}, have {balance}") return balance - amount// Go has no exceptions for ordinary failures. The idiomatic// "Replace Error Code" is to return an explicit error value, so// the failure is part of the signature and callers must handle it.//// Before — -1 sentinelfunc Withdraw(balance, amount int) int { if amount > balance { return -1 } return balance - amount}
// After — explicit errorvar ErrInsufficientFunds = errors.New("insufficient funds")
func Withdraw(balance, amount int) (int, error) { if amount > balance { return 0, fmt.Errorf("%w: need %d, have %d", ErrInsufficientFunds, amount, balance) } return balance - amount, nil}// Rust likewise avoids exceptions for recoverable errors. The// idiomatic move is to return a Result, making failure explicit in// the type — the compiler then nudges every caller to handle it.//// Before — -1 sentinelfn withdraw(balance: i64, amount: i64) -> i64 { if amount > balance { return -1; } balance - amount}
// After — explicit Result#[derive(Debug)]struct InsufficientFunds { need: i64, have: i64,}
fn withdraw(balance: i64, amount: i64) -> Result<i64, InsufficientFunds> { if amount > balance { return Err(InsufficientFunds { need: amount, have: balance }); } Ok(balance - amount)}flowchart TD
A["Function fails"] --> B{"How is failure reported?"}
B -->|"Sentinel: -1 / null / false"| C["Caller must remember to check<br/>silent corruption if they forget"]
B -->|"Exception (TS, Python)"| D["Failure cannot be ignored<br/>unwinds to a handler"]
B -->|"error / Result (Go, Rust)"| E["Failure is explicit in the type<br/>compiler nudges you to handle it"] Mechanics
Section titled “Mechanics”- Identify the sentinel and what condition it stands for. Pin down the exact failure it reports.
- Choose the failure channel for your language. In TypeScript or Python, define a specific exception type — a named class beats a bare
Error/Exception, because callers can catch this failure without swallowing unrelated ones. In Go, return anerror; in Rust, return aResultwith a typed error. - At the failure site, replace
return sentinelwith the new signal:throw/raise, orreturn 0, err/return Err(...). - Update the success path so it returns only real results — and in Go/Rust, pair it with
nil/Ok(...). - Walk every caller. Replace each “did it return the sentinel?” check with the matching handling: a
try/catch(or letting it propagate) for exceptions;if err != nilor?/matchfor Go and Rust. - Run your tests after each caller is migrated, so a missed site shows up immediately rather than after all of them have changed.
When to use / trade-offs
Section titled “When to use / trade-offs”Reach for this when a function reports failure with an in-band sentinel and that sentinel is easy to ignore or ambiguous. Moving failure onto its own channel makes it far harder to mishandle: an unhandled exception unwinds loudly, and an unhandled error/Result is at least visible — and in Rust, the compiler will not let you forget the Err case.
The trade-off is per language. Exceptions are right for exceptional conditions, not for outcomes you expect routinely; using them for ordinary control flow makes code that is slow and hard to follow. If a “missing” result is a normal, expected case, prefer an explicit empty type instead — see Introduce Special Case. In Go and Rust the explicit error/Result style is already idiomatic, so the refactoring there is less “introduce exceptions” and more “stop encoding errors as magic numbers.” Whichever channel you pick, do not mix both for the same function — that just doubles the ways a caller can get it wrong.