All chapters in this track
- 01Chapter 1: Why Coding Standards Matter: Readability, Onboarding, Defects, Consistency at Scale
- 02Chapter 2: Naming Conventions
- 03Chapter 3: Formatting and Style
- 04Chapter 4: Comments and Self-Documenting Code
- 05Chapter 5: Error Handling Conventions
- 06Chapter 6: Code and Project Organization (Files, Modules, Packages)
- 07Chapter 7: Writing Testable Code
- 08Chapter 8: Commit Hygiene: Atomic Commits, Messages, PR-Sized Changes
- 09Chapter 9: Language-Specific Idioms and Anti-Patterns Across TypeScript and Python
- 10Chapter 10: Security-Conscious Basics: Input Validation, Secrets Handling, Injection Risks
- 11Chapter 11: Enforcing Standards Automatically: Linters, Formatters, CI Gates
- 12Chapter 12: Creating, Documenting, and Evolving a Team Style Guide (Exceptions and Legacy Code)
- 13Chapter 13: Worked Example: Refactoring a Messy Function to Standard
- 14Chapter 14: Worked Example: Drafting a Style Guide Section From Scratch
- 15Chapter 15: Common Interview Questions About Coding Standards and Code Quality
Chapter 5 of 15
Chapter 5: Error Handling Conventions
Why this matters
Inconsistent error handling is one of the highest-impact coding standard violations this book covers, because its failures are often silent — a swallowed exception doesn't crash anything visibly, it just quietly loses information that would have explained a bug three weeks later. Getting error handling conventions right, and applying them consistently, directly prevents entire categories of "we have no idea why this happened" production incidents.
The cardinal sin: swallowing errors silently
[BEGINNER] The single most damaging error-handling anti-pattern, in any language:
// NEVER do this: the error vanishes with zero trace of what went wrong
try {
await processPayment(order);
} catch (e) {
// silently ignored
}
# NEVER do this either
try:
process_payment(order)
except Exception:
pass
When this fails in production, there is no log entry, no metric, no trace — the payment silently didn't process, and nothing in the system indicates anything went wrong at all. Debugging this requires noticing a business-level symptom (a customer complaint about a missing charge) and working backward with no technical breadcrumbs whatsoever — an exponentially harder investigation than it needed to be.
The minimum acceptable version — always log, even if you can't do anything else useful:
try {
await processPayment(order);
} catch (e) {
logger.error("Payment processing failed", { orderId: order.id, error: e });
throw e; // or handle it deliberately -- but NEVER silently discard the information
}
Fail fast vs. fail gracefully: a deliberate choice, not a default
[SENIOR INSIGHT] Two legitimate strategies exist, and the standard should specify when each applies, rather than picking one universally:
- Fail fast — surface an error immediately and loudly (throw, crash, alert) when continuing would risk operating on invalid/corrupted state. This is correct for programming errors (violated invariants, impossible states) and for situations where proceeding anyway could cause worse downstream damage (e.g., continuing to process a financial transaction after a data integrity check fails).
- Fail gracefully — catch an error, degrade functionality, and continue operating, when the failure is expected/recoverable and the alternative (crashing entirely) is worse than a partial degradation. This fits expected, recoverable failures — a third-party recommendation service being temporarily unavailable shouldn't crash checkout; it should just mean checkout proceeds without personalized recommendations.
// Fail fast: this represents a violated invariant -- continuing would be dangerous.
function withdraw(account: Account, amount: number): void {
if (amount < 0) {
throw new Error("Withdrawal amount cannot be negative -- this indicates a caller bug");
}
// ...
}
// Fail gracefully: an optional enhancement failing shouldn't break the core flow.
async function getCheckoutPageData(userId: string): Promise<CheckoutData> {
const cart = await getCart(userId); // core functionality -- if this fails, let it fail fast
let recommendations: Product[] = [];
try {
recommendations = await getRecommendations(userId); // OPTIONAL enhancement
} catch (e) {
logger.warn("Recommendations unavailable, proceeding without them", { userId, error: e });
// deliberately continue with an empty array -- checkout should not be blocked by this
}
return { cart, recommendations };
}
[IN PRACTICE] The standard worth codifying explicitly: never choose fail-fast or fail-gracefully by default or by accident — choose deliberately, based on whether the failing operation is core or optional, and whether continuing risks operating on invalid state. A team-wide convention that makes this distinction explicit (e.g., "core business logic fails fast; auxiliary/enhancement features fail gracefully with logging") removes the need to re-litigate this decision for every individual try/catch.
Consistent error types and structured error information
[BEGINNER] A codebase should use a small, consistent set of well-defined error types (or error codes) rather than throwing raw strings or ad hoc, inconsistently-shaped error objects — consistency here lets calling code make reliable decisions about how to respond to different failure categories.
// Inconsistent, hard for callers to handle programmatically:
throw new Error("user not found");
throw { code: 404, msg: "not found" };
throw "User does not exist";
// Consistent: a small hierarchy of well-defined, structured error types
class NotFoundError extends Error {
constructor(public resourceType: string, public resourceId: string) {
super(`${resourceType} with id ${resourceId} not found`);
}
}
class ValidationError extends Error {
constructor(public field: string, public reason: string) {
super(`Validation failed for ${field}: ${reason}`);
}
}
// Callers can now handle categories of failure programmatically and reliably:
try {
await getUser(id);
} catch (e) {
if (e instanceof NotFoundError) {
return response.status(404).json({ error: e.message });
}
throw e; // re-throw anything unexpected -- don't silently absorb unknown error types
}
class NotFoundError(Exception):
def __init__(self, resource_type: str, resource_id: str):
self.resource_type = resource_type
self.resource_id = resource_id
super().__init__(f"{resource_type} with id {resource_id} not found")
class ValidationError(Exception):
def __init__(self, field: str, reason: str):
self.field = field
self.reason = reason
super().__init__(f"Validation failed for {field}: {reason}")
Catching overly broad exception types
[SENIOR INSIGHT] Catching a broad exception type (Exception in Python, the base Error in TypeScript/JavaScript) when only a specific failure is anticipated is a subtle but real anti-pattern: it silently catches and mishandles unrelated errors too — a typo causing an AttributeError/TypeError gets caught by the same handler meant for "the network request failed," masking a completely different bug as if it were the anticipated failure case.
# Too broad: this catches EVERYTHING, including bugs unrelated to the network call
try:
response = requests.get(url)
except Exception:
return None
# Specific: only catches what's actually anticipated, letting unrelated bugs surface normally
try:
response = requests.get(url)
except requests.RequestException:
return None
Common mistakes
- Swallowing an exception with an empty catch block — the single worst error-handling anti-pattern, guaranteed to produce untraceable production incidents.
- Choosing fail-fast or fail-gracefully inconsistently, without a stated team convention — leading to core business logic that fails silently (should have failed fast) or auxiliary features that crash the whole request (should have failed gracefully).
- Throwing raw strings or ad hoc, inconsistently-shaped error objects instead of a small, consistent set of well-defined error types callers can reliably handle programmatically.
- Catching an overly broad exception type, silently absorbing and mishandling unrelated bugs as if they were the anticipated failure case.
Checklist: applying error handling conventions
- Never leave a catch block empty or silent — at minimum, log the error with enough context (relevant IDs, operation name) to investigate later.
- Explicitly decide fail-fast vs. fail-gracefully for each error handling site, based on whether the operation is core or optional, and document the team's default convention for each category.
- Use a small, consistent set of well-defined error types across the codebase, rather than raw strings or ad hoc shapes — this lets calling code branch on error category reliably.
- Catch the narrowest exception type that covers the anticipated failure, never a broad catch-all that could mask unrelated bugs.
Discussion / interview questions
-
"What's wrong with an empty catch block, and how would you fix it?" Strong answer: It silently discards all information about what went wrong, making the resulting production incident nearly impossible to diagnose from technical signals alone; the fix, at minimum, is logging the error with relevant context, and ideally either handling it deliberately or re-throwing it.
-
"How would you decide whether a piece of code should fail fast or fail gracefully on an error?" Strong answer: Fail fast for core business logic or violated invariants where continuing risks worse downstream damage; fail gracefully for optional/auxiliary functionality where a partial degradation is preferable to crashing the whole operation — this should be a deliberate, stated team convention, not an ad hoc, inconsistent choice per site.
-
"Why is catching a broad exception type (like
Exceptionin Python) often considered an anti-pattern?" Strong answer: It silently catches and mishandles errors unrelated to the anticipated failure (e.g., a typo-inducedAttributeErrorgetting caught by a handler meant for network failures), masking a completely different bug as if it were the expected case — catching the narrowest applicable exception type avoids this. -
"Why is a consistent set of well-defined error types better than throwing raw strings?" Strong answer: Calling code can reliably branch on error category (e.g.,
instanceof NotFoundError) to handle different failure types appropriately; raw strings or inconsistently-shaped ad hoc error objects give callers no reliable way to distinguish or programmatically handle different kinds of failures.
Chapter summary / cheat sheet
- Never swallow an error silently — at minimum, log it with enough context to investigate later; an empty catch block is the single worst error-handling anti-pattern.
- Choose fail-fast (core logic, violated invariants) vs. fail-gracefully (optional/auxiliary features) deliberately, per a stated team convention — not by accident or inconsistently.
- Use a small, consistent set of well-defined error types (not raw strings or ad hoc shapes) so calling code can reliably handle different failure categories.
- Catch the narrowest exception type that covers the anticipated failure — a broad catch-all can silently mask unrelated bugs as if they were the expected case.