Vasu Patel logoVasu Patel
← Clean by Default: Coding Standards
All chapters in this track
  1. 01Chapter 1: Why Coding Standards Matter: Readability, Onboarding, Defects, Consistency at Scale
  2. 02Chapter 2: Naming Conventions
  3. 03Chapter 3: Formatting and Style
  4. 04Chapter 4: Comments and Self-Documenting Code
  5. 05Chapter 5: Error Handling Conventions
  6. 06Chapter 6: Code and Project Organization (Files, Modules, Packages)
  7. 07Chapter 7: Writing Testable Code
  8. 08Chapter 8: Commit Hygiene: Atomic Commits, Messages, PR-Sized Changes
  9. 09Chapter 9: Language-Specific Idioms and Anti-Patterns Across TypeScript and Python
  10. 10Chapter 10: Security-Conscious Basics: Input Validation, Secrets Handling, Injection Risks
  11. 11Chapter 11: Enforcing Standards Automatically: Linters, Formatters, CI Gates
  12. 12Chapter 12: Creating, Documenting, and Evolving a Team Style Guide (Exceptions and Legacy Code)
  13. 13Chapter 13: Worked Example: Refactoring a Messy Function to Standard
  14. 14Chapter 14: Worked Example: Drafting a Style Guide Section From Scratch
  15. 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

  1. Swallowing an exception with an empty catch block — the single worst error-handling anti-pattern, guaranteed to produce untraceable production incidents.
  2. 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).
  3. 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.
  4. 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

  1. Never leave a catch block empty or silent — at minimum, log the error with enough context (relevant IDs, operation name) to investigate later.
  2. 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.
  3. 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.
  4. Catch the narrowest exception type that covers the anticipated failure, never a broad catch-all that could mask unrelated bugs.

Discussion / interview questions

  1. "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.

  2. "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.

  3. "Why is catching a broad exception type (like Exception in Python) often considered an anti-pattern?" Strong answer: It silently catches and mishandles errors unrelated to the anticipated failure (e.g., a typo-induced AttributeError getting 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.

  4. "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.