Skip to main content
CleanCodeMastery

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.

27 min read Updated June 11, 2026beginner
refactoringexceptionserror codeserror handlingresult typetypescriptcsharp

📢 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.

Figure 1: Priya's form at the whispering counter and at the announcing counter

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.

  1. 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.
  2. 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?"
  3. 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 -1 carries 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.

Figure 2: The four families of error reporting — this refactoring moves you from the first branch to the second

And here is the head-to-head comparison, the table worth memorising for viva and interviews alike.

QuestionError code (-1, null, false)Exception
Can the caller ignore it?Yes — silently, accidentally, foreverNo — unhandled means a loud stop with a stack trace
Where does an ignored failure surface?Far downstream, days later, in unrelated codeAt the exact line, immediately
What information travels with the failure?One magic value, nothing elseType, message, stack trace, custom fields
What happens to the return value?Hijacked — it carries answers and signalsFreed — it carries only honest answers
What do middle layers do?Translate codes between numbering systemsNothing — the exception passes through on its own channel
Cost on the happy path?NoneNone (modern runtimes pay only when throwing)
Cost on the failure path?One comparisonExpensive — 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 like STATUS_FAILED to 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.

Figure 3: How failures were reported in the legacy codebase before the refactoring

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 moved

After 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.

Figure 4: An ignored error code flows silently into data; an unhandled exception stops at the cause

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.

Figure 5: With exceptions, a rejected form cannot silently continue — the only path forward is the fix

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.

Figure 6: With exceptions, middle layers stop translating failure codes — announcements travel up on their own channel

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.

Figure 7: The exception family — the hierarchy is the routing plan for failures

💼 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., intvoid) across all callers in one safe operation.
  • Live templates / snippets generate well-formed custom exception classes (exc templates 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-empty and @typescript-eslint rules catch silent catch {} blocks.
  • Compiler help: in TypeScript, changing the return type from number to void makes every leftover === -1 comparison 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.

BenefitsRisks / costs
Failure cannot be silently ignored — it propagates until someone handles itThrowing 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 == ...) laddersExceptions 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 traceOverly broad catch blocks can hide unrelated bugs behind business handling
Middle layers shrink — no code-translation plumbing between floorsUsing exceptions as ordinary control flow is an anti-pattern that hides the real program flow
The return value regains a single honest meaningAcross 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.

Figure 8: Silent failures reaching production per month, before and after the refactoring

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.

Figure 9: Two questions decide the tool — expectedness and testability

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?

SmellHow this refactoring helps
Error codes / magic sentinel valuesDirectly removes them — failure gets its own typed channel
Primitive ObsessionA meaning-laden integer (-1, ERR_FROZEN = 2) becomes a real class with named fields
Long MethodStatus-checking ladders interleaved with logic disappear; methods keep only their real work
Duplicate CodeThe same if (result == -1) dance copy-pasted at every call site is replaced by one handler at the right level
Switch StatementsSwitches 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't

Do the refactoring yourself, step by step:

  1. 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.)
  2. Create BookNotFoundError, AlreadyBorrowedError (carrying who borrowed it), and BorrowLimitError (carrying the limit). Make borrowBook throw them and return void.
  3. 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.
  4. Confirm the win: explain in one sentence why the "Book issued successfully" lie is now impossible to write by accident.
  5. 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?
  6. Bonus thinking: rewrite borrowBook once 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?
  7. 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