Skip to content

Replace Error Code with Exception

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.

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.

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 failure
function withdraw(balance: number, amount: number): number {
if (amount > balance) {
return -1;
}
return balance - amount;
}
// After — failure cannot be confused with a balance
class InsufficientFundsError extends Error {}
function withdraw(balance: number, amount: number): number {
if (amount > balance) {
throw new InsufficientFundsError(`need ${amount}, have ${balance}`);
}
return 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"]
Failure leaves the value channel — exception in TS/Python, error or Result in Go/Rust
  1. Identify the sentinel and what condition it stands for. Pin down the exact failure it reports.
  2. 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 an error; in Rust, return a Result with a typed error.
  3. At the failure site, replace return sentinel with the new signal: throw/raise, or return 0, err / return Err(...).
  4. Update the success path so it returns only real results — and in Go/Rust, pair it with nil/Ok(...).
  5. 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 != nil or ?/match for Go and Rust.
  6. Run your tests after each caller is migrated, so a missed site shows up immediately rather than after all of them have changed.

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.

Why is an error code (like returning -1) risky?
For Go and Rust, what does the lesson present as the idiomatic "Replace Error Code" instead of exceptions?
When a "missing" result is a normal, expected case rather than a true failure, what does the lesson suggest instead?
Why prefer a specific exception type over a bare Error/Exception in TypeScript or Python?