Replace Error Code with Exception: Stop Whispering Failures, Announce Them
Learn the Replace Error Code with Exception refactoring with a government-office story, before/after TypeScript and C#, safe migration steps, and an honest comparison with Result types as the modern third way.
📢 The clerk who whispers and the clerk who announces
Picture a busy government office in Lucknow. Priya, a final-year student, has come to submit her scholarship form. She has photocopied everything twice, stood in the morning sun, and waited forty minutes in queue. Finally she hands her form to the clerk at counter number 3 — Dinesh babu, who has been at this counter for eleven years and has a very strange habit.
If something is wrong with a form, Dinesh babu does not say a word. He scribbles a tiny "-1" in pencil in the bottom corner, puts the form back in your hand, and calls the next person. That is all. No announcement. No explanation. The "-1" is an internal office code, known to clerks and to nobody else. If you do not know about the secret code — and Priya does not — you walk home happy, thinking your scholarship is on the way.
Three months later: no scholarship, no record, no clue. Priya travels back, stands in the same queue, and finally a senior clerk turns her form over and points at the faded pencil mark. "Photograph missing. See? Minus one." The failure happened at counter 3 in March; she discovered it in June, in another city, with the original cause long forgotten and the deadline gone.
Now walk with Priya to counter number 7, where Shabana madam sits. She is different. The moment she finds a problem, she holds up the form and announces clearly, loudly enough for the whole hall: "Form rejected — photograph missing!" Everyone hears it. There is no way to leave counter 7 believing everything is fine. Slightly embarrassing? Maybe. But Priya fixes the photo today, at the photo shop next door, while she is still standing right there and the cause is fresh.
The pencil "-1" is an error code. The loud announcement is an exception. Today's refactoring, Replace Error Code with Exception, retrains counter 3 to behave like counter 7 — so that failure can never again slip past unnoticed.
Look at the journey scores honestly. The silent counter actually feels better in the middle — Priya walks home happy! That is precisely what makes error codes so dangerous: the moment of failure is painless. The pain arrives later, larger, and far from the cause. The loud counter has one uncomfortable moment, and then everything after it is good. Exceptions trade a small immediate sting for the end of silent disasters.
📜 What is Replace Error Code with Exception?
Many older codebases (and many C-style APIs) report failure by returning a magic value: -1, 0, null, false, or a numeric constant like ERR_NO_FUNDS = 7. The method's single return value is doing two jobs at once — sometimes it carries the real answer, sometimes it carries a secret distress signal.
This design has one fatal weakness: nothing forces the caller to look. Checking the code is voluntary. And in any codebase with many callers, "voluntary" eventually means "forgotten somewhere." The forgotten check does not crash. It does something far worse — it lets the program continue calmly with a wrong value, which flows downstream, gets saved, gets added, gets printed, until something breaks in a completely different module. Debugging then means walking backwards from the visible damage to the silent cause, sometimes across days of logs. Every experienced programmer has lost a weekend to a pencil "-1".
Replace Error Code with Exception says: when the method fails, throw an exception instead of returning a code. The refactoring comes straight from Martin Fowler's Refactoring catalog, and refactoring.guru's treatment adds a memorable point — returning error codes is an obsolete holdover from procedural programming, from days when functions had no other way to shout.
An exception changes the physics of failure in three ways.
- It cannot be silently ignored. If no one handles it, it travels up the call stack and stops the program loudly, with a stack trace pointing at the exact line. Shabana madam's announcement, with the queue number attached.
- It separates the two channels. The return value now carries only real answers. Failure travels on its own dedicated path. No more "is this -1 a temperature reading or an error?"
- It carries rich context. An exception is a full object: a message, a type, a stack trace, and any custom fields you add — account ID, amounts, timestamps. A
-1carries nothing. It cannot even say which photograph is missing.
One-line summary: an error code is a whisper the caller may miss; an exception is an announcement that keeps travelling up the building until somebody responsible deals with it.
Before we go further, here is the whole landscape of failure-reporting styles in one picture, because this post sits inside a bigger map.
And here is the head-to-head comparison, the table worth memorising for viva and interviews alike.
| Question | Error code (-1, null, false) | Exception |
|---|---|---|
| Can the caller ignore it? | Yes — silently, accidentally, forever | No — unhandled means a loud stop with a stack trace |
| Where does an ignored failure surface? | Far downstream, days later, in unrelated code | At the exact line, immediately |
| What information travels with the failure? | One magic value, nothing else | Type, message, stack trace, custom fields |
| What happens to the return value? | Hijacked — it carries answers and signals | Freed — it carries only honest answers |
| What do middle layers do? | Translate codes between numbering systems | Nothing — the exception passes through on its own channel |
| Cost on the happy path? | None | None (modern runtimes pay only when throwing) |
| Cost on the failure path? | One comparison | Expensive — fine for rare events, wrong for routine ones |
That last row is the honest one, and we will return to it: exceptions are for the unexpected. The expected and routine deserves a different tool — covered by this post's twin, Replace Exception with Test.
🔍 When do we need it?
Look for these signs in your code.
- Sentinel return values. Methods returning
-1,null,false, or constants likeSTATUS_FAILEDto mean "it went wrong." Especially dangerous when the method also returns real numbers — can the temperature be -1? Then -1 cannot mean error. - Callers that forget to check. Search for call sites of such a method. If even one caller uses the return value without comparing it against the error value, you have a live bug waiting for its moment.
- Error handling tangled with the happy path. Step ladders of
if (result == -1) ... else if (result == -2) ...interleaved with normal logic, making methods long and unreadable. - Failures discovered far from their cause. Bug reports like "the report shows ₹-1 crore" mean a sentinel leaked into data long ago.
- Magic constants documenting failure kinds.
const ERR_NO_BALANCE = 1; const ERR_FROZEN = 2;— a parallel universe of integers that only the original author can decode. This is the Primitive Obsession smell wearing an error-handling costume.
When the scholarship office finally audited its form-processing software, the team counted how failures had been reported across the codebase. The pie was not pretty.
Half of all failures were pencil scribbles. Another third were console.log("save failed") lines — announcements made into an empty room, because nobody watches the console of a production server. Only one failure in five actually stopped the program and demanded attention.
And one sign that you should pause: is the "failure" actually expected and routine? A search that finds nothing is not a failure. A user typing letters into a phone-number field is Tuesday, not a disaster. For those cases, exceptions are the wrong loudspeaker — see the honest discussion in the benefits section, and the twin refactoring Replace Exception with Test, which moves in exactly the opposite direction. These two refactorings are a pair, and choosing between them is the real skill.
⚖️ Before and after at a glance
Here is the scholarship office's banking module, before. Failure is a pencil scribble.
// BEFORE: -1 means failure... if anyone remembers to look
const ERR_INSUFFICIENT = -1;
function withdraw(account: Account, amount: number): number {
if (amount > account.balance) {
return ERR_INSUFFICIENT; // the whisper
}
account.balance -= amount;
return 0; // success... also a magic number
}
// Caller A remembers the secret code:
if (withdraw(acc, 500) === ERR_INSUFFICIENT) {
notifyUser("Not enough balance");
}
// Caller B forgot. The failure vanishes. Balance is wrong forever.
withdraw(acc, 99999);
sendReceipt(acc); // happily sends a receipt for money that never movedAfter the refactoring, failure announces itself.
// AFTER: failure is thrown, not returned
class InsufficientFundsError extends Error {
constructor(
readonly accountId: string,
readonly requested: number,
readonly available: number,
) {
super(
`Account ${accountId}: requested ${requested}, ` +
`only ${available} available`,
);
this.name = "InsufficientFundsError";
}
}
function withdraw(account: Account, amount: number): void {
if (amount > account.balance) {
throw new InsufficientFundsError(account.id, amount, account.balance);
}
account.balance -= amount;
}
// Caller A handles it explicitly:
try {
withdraw(acc, 500);
} catch (e) {
if (e instanceof InsufficientFundsError) {
notifyUser(`Not enough balance: you have ${e.available}`);
} else {
throw e; // not ours — let it travel upward
}
}
// Caller B "forgot" again — but now the program STOPS at this line,
// with a stack trace, before any wrong receipt is sent.
withdraw(acc, 99999);Notice the deeper changes. The return type became void — the function no longer pretends its return value is a status report. The error object carries the account ID and both amounts, so whoever catches it can write a useful message or log. And the forgetful caller is no longer a silent data-corruption bug; it is a loud, immediate, debuggable crash at the true scene of the crime.
There is also a state-machine way to see what the exception buys us. With a thrown rejection, a form simply cannot drift into the accepted states while broken — the only road out of Rejected is fixing the problem.
Compare that with the error-code world, where there is an invisible extra arrow from Rejected straight to Accepted, taken whenever any caller forgets to check. The whole refactoring can be summarised as: delete the invisible arrow.
🪜 Step-by-step, the safe way
Changing how a method reports failure touches every caller, so we go gently.
Step 1: Decide whether the failure is truly exceptional. This is the step most people skip, and it is the most important one. Ask: is this outcome rare and abnormal (overdraft, corrupted record, broken invariant)? Then proceed. Is it routine and expected (item not found, empty input)? Then stop — you may want a Try... method or a Result type instead, not an exception.
Step 2: Create a specific exception type that carries context. Not a bare Error("failed"). Give it the data a handler will need — the account ID, the requested and available amounts, the field name. Shabana madam does not shout "Form rejected!"; she shouts "Form rejected — photograph missing."
Step 3: Throw alongside the code first, behind a safety check. A lovely intermediate trick: keep returning the error code, but assert that callers are behaving. Or simpler — change the method to throw, but keep a temporary wrapper with the old name for unmigrated callers.
// INTERMEDIATE: new throwing version + old wrapper kept temporarily
function withdrawOrThrow(account: Account, amount: number): void {
if (amount > account.balance) {
throw new InsufficientFundsError(account.id, amount, account.balance);
}
account.balance -= amount;
}
/** @deprecated migrate to withdrawOrThrow */
function withdraw(account: Account, amount: number): number {
try {
withdrawOrThrow(account, amount);
return 0;
} catch (e) {
if (e instanceof InsufficientFundsError) return -1;
throw e;
}
}Old callers keep compiling. New code uses the honest version. You migrate at your own pace, tests green throughout.
Step 4: Migrate callers one by one. At each call site, replace the === -1 check with try/catch — or, often better, with nothing: if this caller cannot meaningfully handle the failure, let the exception travel upward to a layer that can. Not every floor of the building needs its own announcement desk.
Step 5: Delete the wrapper, the error constants, and fix the return type. When "find usages" shows the old withdraw is unused, remove it. Remove ERR_INSUFFICIENT. The method now returns only meaningful results — often void.
Step 6: Test both paths. One test for the happy withdrawal, one asserting that the right exception (with the right data inside) is thrown for the overdraft.
Two traps to avoid while migrating. First, the empty catch block: catch (e) {} recreates the original silent-failure problem with extra ceremony — never swallow what you cannot handle. Second, the catch-everything block: catch (Exception) will also catch null-reference bugs and typos and report them as "insufficient funds." Catch the specific type you understand; rethrow or ignore the rest so real bugs stay visible. And when you wrap an exception in another, always attach the original as the inner exception so the stack trace survives.
College corner: what does a throw actually cost? Students hear "exceptions are slow" and "exceptions are free" from different professors, and both are half-right. Modern runtimes (the JVM, .NET, C++ with table-based unwinding) implement what is called zero-cost exception handling on the happy path: entering a try block costs essentially nothing, because the handler locations live in static tables consulted only when a throw happens. The bill arrives at throw time: the runtime must capture a stack trace, walk the unwind tables frame by frame, run cleanup code, and match handler types — work measured in microseconds, versus nanoseconds for a plain return. A factor of hundreds to thousands. For Shabana madam this is the right trade: rejecting a form is rare, and the announcement must be impossible to miss, so a microsecond is nothing. But put a throw inside a million-row parsing loop and your import slows from seconds to minutes. The engineering rule that falls out: frequency decides the tool. Rare and abnormal → throw, gladly. Frequent and expected → check first or return a Result. This is exactly the boundary line between this refactoring and its twin.
🏢 A bigger real-life example
Let us follow the scholarship office itself. The form-processing service used layered error codes — and codes calling codes is where this design truly collapses, because every floor of the building must learn every other floor's secret pencil marks.
// BEFORE: every layer translates the lower layer's codes. Madness grows.
function validatePhoto(form: Form): number {
if (!form.photo) return 1; // 1 = missing
if (form.photo.sizeKb > 200) return 2; // 2 = too big
return 0;
}
function processForm(form: Form): number {
const photoResult = validatePhoto(form);
if (photoResult === 1) return 101; // translate: 1 -> 101
if (photoResult === 2) return 102; // translate: 2 -> 102
if (!form.signature) return 103;
saveToRegistry(form); // returns its own codes... ignored!
return 0;
}
// UI layer decodes a third numbering system:
const code = processForm(form);
if (code === 101) show("Photo missing");
else if (code === 102) show("Photo too large");
else if (code === 103) show("Signature missing");Count the problems. Three numbering systems to keep in sync. The saveToRegistry return code is silently dropped — a real bug, invisible, sitting in the middle of the "error handling" itself. Half of every function is plumbing that just carries numbers upstairs. Now the exception version:
// AFTER: each problem is a typed announcement; middle layers carry nothing
class FormRejectedError extends Error {
constructor(readonly reason: string, readonly field: string) {
super(`Form rejected — ${reason}`);
this.name = "FormRejectedError";
}
}
class RegistryError extends Error {
constructor(readonly formId: string, cause: unknown) {
super(`Registry save failed for ${formId}`, { cause });
this.name = "RegistryError";
}
}
function validatePhoto(form: Form): void {
if (!form.photo)
throw new FormRejectedError("photograph missing", "photo");
if (form.photo.sizeKb > 200)
throw new FormRejectedError("photograph larger than 200 KB", "photo");
}
function processForm(form: Form): void {
validatePhoto(form); // no translation layer at all
if (!form.signature)
throw new FormRejectedError("signature missing", "signature");
saveToRegistry(form); // its failure now travels up too
}
// UI layer — the one place that talks to the user:
try {
processForm(form);
show("Form accepted!");
} catch (e) {
if (e instanceof FormRejectedError) {
show(e.message); // "Form rejected — photograph missing"
highlightField(e.field);
} else {
show("Something went wrong. Please try again.");
reportToSupport(e); // RegistryError and unknowns go here
}
}The middle layer shrank to its real logic. The three numbering dictionaries are gone. The dropped saveToRegistry failure is now impossible to drop. And the UI can highlight the exact field, because the exception carries it.
The exceptions themselves deserve a small design of their own. They are a class hierarchy, and the hierarchy is your catch-policy: catch FormRejectedError where you talk to users, catch RegistryError where you talk to support, and let everything else fly to the top-level handler.
💼 The same refactoring in C#
C# was built with this philosophy in its bones. Here is the withdrawal, the .NET way.
public class InsufficientFundsException : Exception
{
public string AccountId { get; }
public decimal Requested { get; }
public decimal Available { get; }
public InsufficientFundsException(
string accountId, decimal requested, decimal available)
: base($"Account {accountId}: requested {requested:C}, " +
$"only {available:C} available")
{
AccountId = accountId;
Requested = requested;
Available = available;
}
}
public class BankService
{
public void Withdraw(Account account, decimal amount)
{
if (amount > account.Balance)
throw new InsufficientFundsException(
account.Id, amount, account.Balance);
account.Balance -= amount;
}
}
// Caller — catch exactly what you can handle:
try
{
bank.Withdraw(account, 500m);
}
catch (InsufficientFundsException ex)
{
Console.WriteLine($"Sorry — only {ex.Available:C} available.");
}Three .NET-specific notes. First, derive from Exception directly and end the class name with Exception — that is the framework convention. Second, when rethrowing inside a catch block, write throw; not throw ex; — the second form destroys the original stack trace, erasing the very evidence exceptions exist to preserve. Third, at service boundaries exceptions must be translated, not thrown across the wire: an ASP.NET API maps InsufficientFundsException to an HTTP 422 with a problem-details body, because HTTP speaks status codes, not .NET objects. Error codes at protocol boundaries are fine and normal — the refactoring targets error codes inside your program.
And for completeness, the same idea in Python, where custom exception classes are two lines and the culture strongly favours raising over returning sentinels:
class InsufficientFundsError(Exception):
def __init__(self, account_id: str, requested: float, available: float):
super().__init__(
f"Account {account_id}: requested {requested}, only {available} available"
)
self.account_id = account_id
self.requested = requested
self.available = available
def withdraw(account, amount: float) -> None:
if amount > account.balance:
raise InsufficientFundsError(account.id, amount, account.balance)
account.balance -= amount
# The caller catches exactly what it understands:
try:
withdraw(acc, 500)
except InsufficientFundsError as err:
notify_user(f"Not enough balance: you have {err.available}")College corner: the checked exceptions debate. Java tried to solve "the caller might not know this can fail" with checked exceptions: a method declares throws InsufficientFundsException, and the compiler forces every caller to either catch it or declare it onward. On paper, this is wonderful — the invisible failure channel becomes visible in the signature, the compiler becomes Shabana madam. In practice, twenty-five years of Java taught the industry the costs: signatures snowball as every layer re-declares everything below it; lazy programmers silence the compiler with empty catch blocks (recreating the silent pencil mark, now with the compiler's blessing); and checked exceptions interact terribly with lambdas and streams. C# watched all this and deliberately shipped with unchecked exceptions only — Anders Hejlsberg argued that forcing handling produces ritual, not robustness. Kotlin and Swift each landed in different middle grounds, and the modern functional answer is to put expected failures in the return type instead — which is exactly the Result-type idea below. The exam-worthy summary: checked exceptions tried to make the failure channel visible in the signature; the idea was right, the ergonomics were wrong, and Result types are the modern retry of that same idea.
🛠️ IDE support
There is no one-click "Replace Error Code with Exception" in any major IDE — this refactoring changes behaviour contracts, so it needs a human brain. But the tooling still helps a lot.
- Find Usages (IntelliJ, Rider, VS Code, Visual Studio) gives you the complete checklist of call sites that compare against the error code — your migration list for step 4.
- Change Signature refactoring updates the return type (e.g.,
int→void) across all callers in one safe operation. - Live templates / snippets generate well-formed custom exception classes (
exctemplates in Rider and ReSharper). - Static analyzers are your watchdogs afterwards: SonarLint flags empty catch blocks and exceptions used for control flow; .NET analyzers warn on overly broad catches (CA1031) and on swallowed exceptions; ESLint's
no-emptyand@typescript-eslintrules catch silentcatch {}blocks. - Compiler help: in TypeScript, changing the return type from
numbertovoidmakes every leftover=== -1comparison a compile error — a free, complete list of unmigrated callers.
⚖️ Benefits and risks
Here is the honest ledger — and the bigger picture, because this post and Replace Exception with Test are two halves of one lesson: exceptions for unexpected problems, upfront checks for expected conditions.
| Benefits | Risks / costs |
|---|---|
| Failure cannot be silently ignored — it propagates until someone handles it | Throwing is expensive (often hundreds of times slower than a return) — wrong tool for routine, frequent outcomes |
Happy-path code reads cleanly, no longer braided with if (code == ...) ladders | Exceptions are invisible in signatures (in TS/C#) — callers cannot see what may be thrown without reading docs |
| Rich context travels with the failure: message, custom fields, stack trace | Overly broad catch blocks can hide unrelated bugs behind business handling |
| Middle layers shrink — no code-translation plumbing between floors | Using exceptions as ordinary control flow is an anti-pattern that hides the real program flow |
| The return value regains a single honest meaning | Across process/HTTP boundaries exceptions must be mapped back to status codes anyway |
Did the scholarship office's refactoring actually pay off? The team tracked one metric for six months: failures that reached production data without anyone noticing at the time they happened.
Nine silent failures a month became one — and that one came from a leftover catch (e) {} block someone had written to "make the red squiggle go away," proving the warning callout above earns its place.
So how do you decide, for each individual failure, whether it deserves an exception at all? Two questions settle it: is the condition expected or truly abnormal? and can the caller cheaply test for it beforehand? Plot any failure on those two axes and the answer reads off the chart.
And now the third way, because the modern world offers one. Result types — Rust's built-in Result<T, E>, Either from fp-ts in TypeScript, and C# libraries like OperationResult or ErrorOr — return an object that explicitly holds either a success value or an error. The compiler then refuses to let you touch the value without first handling the error case. In Rust this is famously strict: code that ignores the Err arm simply does not compile.
Result types combine the best of both worlds for expected failures: they are loud like exceptions (the type system forces a decision) yet cheap like return values (no stack unwinding). Newer languages noticed — Rust and Go shipped without exceptions at all. The honest balance sheet: Results add ceremony in languages built around exceptions, every layer must pass them along explicitly, and a half-adopted Result style (some functions throw, some return Results) is more confusing than either pure style. A practical rule for today's TypeScript/C# codebases: exceptions for the truly exceptional, Result types or Try-methods for expected domain failures, and never, ever bare error codes.
🧹 Which smells does it cure?
| Smell | How this refactoring helps |
|---|---|
| Error codes / magic sentinel values | Directly removes them — failure gets its own typed channel |
| Primitive Obsession | A meaning-laden integer (-1, ERR_FROZEN = 2) becomes a real class with named fields |
| Long Method | Status-checking ladders interleaved with logic disappear; methods keep only their real work |
| Duplicate Code | The same if (result == -1) dance copy-pasted at every call site is replaced by one handler at the right level |
| Switch Statements | Switches decoding numeric error kinds become typed catch blocks — or vanish into upward propagation |
📋 Quick revision box
+------------------------------------------------------------------+
| REPLACE ERROR CODE WITH EXCEPTION - REVISION CARD |
+------------------------------------------------------------------+
| Problem : method returns -1 / null / false to signal failure |
| -> checking is voluntary -> someone forgets -> |
| wrong data travels silently, breaks far away |
| Solution : THROW a specific exception carrying context; |
| return value keeps only its honest meaning |
| |
| RULES OF THE ANNOUNCEMENT: |
| - specific exception type, rich fields, clear message |
| - never swallow: empty catch = the old bug, fancier |
| - catch only what you can handle; let the rest travel |
| - C#: rethrow with "throw;" never "throw ex;" |
| |
| THE PAIR : unexpected problem -> EXCEPTION (this post) |
| expected condition -> TEST first (twin post) |
| THIRD WAY: Result / Either types - compiler-checked, cheap, |
| great for expected domain failures |
+------------------------------------------------------------------+✏️ Practice exercise
Your turn. A library app manages book borrowing with error codes — and it already has a silent bug.
const ERR_NOT_FOUND = -1;
const ERR_ALREADY_BORROWED = -2;
const ERR_LIMIT_REACHED = -3;
function borrowBook(memberId: string, bookId: string): number {
const book = findBook(bookId);
if (!book) return ERR_NOT_FOUND;
if (book.borrowedBy) return ERR_ALREADY_BORROWED;
if (countBorrowed(memberId) >= 3) return ERR_LIMIT_REACHED;
book.borrowedBy = memberId;
return 0;
}
// Somewhere in the UI:
borrowBook(member.id, selectedBook.id); // return value ignored!
showMessage("Book issued successfully"); // ...even when it wasn'tDo the refactoring yourself, step by step:
- First, the thinking step: which of the three failures are truly exceptional, and which are expected user-facing outcomes? Write one sentence for each. (Hint: a member picking an already-borrowed book is rather normal in a library.)
- Create
BookNotFoundError,AlreadyBorrowedError(carrying who borrowed it), andBorrowLimitError(carrying the limit). MakeborrowBookthrow them and returnvoid. - Fix the UI bug: wrap the call in
try/catch, show a specific friendly message per error type, and show the success message only on success. - Confirm the win: explain in one sentence why the "Book issued successfully" lie is now impossible to write by accident.
- Plot all three failures on the Figure 9 quadrant chart. Do they all land in the "throw" quadrant, or did step 1 already tell you some belong elsewhere?
- Bonus thinking: rewrite
borrowBookonce more to return a Result-style union —{ ok: true } | { ok: false; reason: "not-found" | "already-borrowed" | "limit" }— and note which version forces the UI programmer to handle every case. Which style would you choose for this app, and why? - Stretch goal: read the twin refactoring Replace Exception with Test and decide — should "book not found when scanning a barcode" be an exception or an upfront check? Defend your answer in two sentences.
If you can explain to a friend why an ignored -1 is more dangerous than a crash — why Priya was better off being embarrassed at counter 7 than walking home happy from counter 3 — you have understood this refactoring completely.
Frequently asked questions
- What exactly is an error code?
- An error code is a special return value — minus one, null, false, or a numeric constant — that secretly means the operation failed. The danger is that nothing forces the caller to check it, so a forgotten check lets a failure travel silently through the program until something breaks far away from the real cause.
- When should I prefer exceptions over error codes?
- When the failure is genuinely unexpected and must not be ignored — a broken invariant, a missing file that should exist, an overdraft. An unhandled exception stops the program loudly at the right place, while an unchecked error code corrupts data quietly.
- Are exceptions slow?
- Throwing an exception is much more expensive than returning a value, often by a factor of hundreds or thousands. That cost is irrelevant for rare, truly exceptional failures, but it matters if you throw on routine paths. For expected, frequent conditions, use an upfront check or a Result type instead.
- What is a Result type and is it better than both options?
- A Result type, like Rust's Result, fp-ts Either, or C# Result libraries, is an object that explicitly holds either a success value or an error. The compiler forces the caller to handle both cases, combining the loudness of exceptions with the cheapness of return values. It is excellent for expected failures, but it adds ceremony and works best when the whole team adopts it consistently.
- Is it okay to catch the base Exception type?
- Usually no. Catching everything can hide unrelated bugs like null references behind your business handling. Catch the specific exception types you actually know how to handle and let the rest travel upward to a boundary handler.
Further reading
Related Lessons
Replace Exception with Test: Read the Board Before You Walk In
Learn the Replace Exception with Test refactoring (Replace Exception with Precheck) with a canteen story, before/after TypeScript and C#, TryParse-style patterns, the check-then-act race-condition trap, and Result types as the modern third way.
Primitive Obsession: When Everything Is Just a String or a Number
Primitive Obsession explained simply — why plain strings and numbers hide bugs, and how value objects like Money and Address make code safe and clear.
Long Method: When One Function Tries to Do Everything
Learn the Long Method code smell with simple stories, TypeScript and C# examples, and step-by-step refactoring using Extract Method. Beginner friendly guide.
Switch Statements: The Receptionist With the Giant Rulebook
Learn the Switch Statements code smell with a school receptionist story, duplicated switch examples in TypeScript and C#, and the polymorphism cure.