Lesson 39 of 54

Error Handling Done Right

Fail Fast and Defensive Programming

A payment service once processed an order with a negative quantity. The bug didn't surface until three days later, when accounting noticed a refund that shouldn't exist. By then, the bad data had propagated through inventory, shipping, and reporting. Fixing it required manual corrections across five systems.

The mistake wasn't the negative quantity—it was accepting it in the first place.

Fail fast means detecting errors as early as possible and failing immediately with a clear signal. Don't let invalid data travel. Don't let broken assumptions persist. Stop at the boundary.

The Fail-Fast Principle

When something is wrong, fail immediately. Don't continue with corrupted state. Don't return a half-baked result. Don't hope the next layer will catch it.

Why Fail Fast?

Easier debugging. If you fail at the point of the error, the stack trace points to the source. If you fail three layers later, you're debugging symptoms.

No corrupted state. Invalid data that propagates corrupts everything it touches. A null that becomes undefined that becomes NaN in a calculation—good luck tracing that.

Clear contracts. Failing fast at boundaries makes your function's contract explicit: "I expect valid input. If you give me garbage, I'll tell you immediately."

Javascript
// BAD: Silent corruption
function calculateTotal(items) {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
// Pass [{ price: 10, quantity: -1 }] → returns -10. No error. Just wrong.

// GOOD: Fail fast at the boundary
function calculateTotal(items) {
  if (!Array.isArray(items)) {
    throw new Error('calculateTotal expects an array of items');
  }
  for (const item of items) {
    if (typeof item.price !== 'number' || item.price < 0) {
      throw new Error(`Invalid price: ${item.price}`);
    }
    if (typeof item.quantity !== 'number' || item.quantity < 0) {
      throw new Error(`Invalid quantity: ${item.quantity}`);
    }
  }
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

Assertions vs. Validation

Both detect problems early, but they serve different purposes.

Validation checks external input. User data, API responses, file contents—anything from outside your system. Validation says: "I don't trust this data. Let me verify it before I use it."

Assertions check internal invariants. They say: "This should never be false. If it is, I have a bug." Assertions are for programmer errors, not user errors.

Typescript
// Validation: external input
function processOrder(order: unknown) {
  if (!order || typeof order !== 'object') {
    throw new Error('Order must be an object');
  }
  const o = order as Record<string, unknown>;
  if (typeof o.total !== 'number' || o.total < 0) {
    throw new Error('Order total must be a non-negative number');
  }
  // ...
}

// Assertion: internal invariant
function withdraw(account: Account, amount: number) {
  // We just checked balance. This should never fail.
  if (account.balance < amount) {
    throw new Error(`Invariant violated: balance ${account.balance} < amount ${amount}`);
  }
  account.balance -= amount;
}

In JavaScript/TypeScript, there's no built-in assert. Node has assert in the standard library; for browsers, you can use a simple helper:

Typescript
function assert(condition: unknown, message: string): asserts condition {
  if (!condition) {
    throw new Error(`Assertion failed: ${message}`);
  }
}

function processUser(user: User | null) {
  assert(user !== null, 'User must exist at this point');
  // TypeScript now knows user is User, not null
  console.log(user.name);
}

Defensive Programming: Validating Inputs

Validate at system boundaries. The edge of your system is where untrusted data enters: API endpoints, file uploads, user input, external service responses.

What to Validate

  • Type and shape: Is it the right structure? Does it have the expected fields?
  • Range and format: Is the number in a valid range? Is the string a valid email?
  • Business rules: Does the order have items? Is the user authorized?
Typescript
// Boundary: API request handler
async function createOrder(req: Request): Promise<Order> {
  const body = await req.json();
  
  if (!body.items || !Array.isArray(body.items)) {
    throw new BadRequestError('Order must include items array');
  }
  
  if (body.items.length === 0) {
    throw new BadRequestError('Order must have at least one item');
  }
  
  for (const item of body.items) {
    if (typeof item.productId !== 'string' || !item.productId) {
      throw new BadRequestError('Each item must have a productId');
    }
    if (typeof item.quantity !== 'number' || item.quantity < 1) {
      throw new BadRequestError('Quantity must be a positive integer');
    }
  }
  
  return orderService.create(body);
}

Schema Validation Libraries

For complex validation, use a schema library. They give you declarative validation and clear error messages:

Typescript
import { z } from 'zod';

const OrderItemSchema = z.object({
  productId: z.string().min(1),
  quantity: z.number().int().positive(),
});

const CreateOrderSchema = z.object({
  items: z.array(OrderItemSchema).min(1),
  customerId: z.string().uuid().optional(),
});

async function createOrder(req: Request): Promise<Order> {
  const parsed = CreateOrderSchema.safeParse(await req.json());
  
  if (!parsed.success) {
    throw new BadRequestError(parsed.error.message);
  }
  
  return orderService.create(parsed.data);
}

The Balance: Not Over-Defensive

Defensive programming has a cost. Validate too little and you get corrupted state. Validate too much and you get noise, performance overhead, and code that's hard to read.

When to Validate

  • At boundaries: Where external data enters your system
  • When the cost of failure is high: Payments, auth, data integrity
  • When the caller might be wrong: Public APIs, libraries, shared modules

When to Skip

  • Internal functions with trusted callers: If you control all call sites, trust your types
  • Performance-critical paths: Validate once at the boundary, not in hot loops
  • When types guarantee correctness: TypeScript narrows types; redundant runtime checks add little
Typescript
// Over-defensive: internal function, caller is trusted
function add(a: number, b: number): number {
  if (typeof a !== 'number' || typeof b !== 'number') {
    throw new Error('Arguments must be numbers');
  }
  if (!Number.isFinite(a) || !Number.isFinite(b)) {
    throw new Error('Arguments must be finite');
  }
  return a + b;
}
// TypeScript already enforces number. The caller is your own code. This is noise.

// Right amount: validate at boundary
function handleCalculatorInput(userInput: unknown): number {
  if (typeof userInput !== 'number' || !Number.isFinite(userInput)) {
    throw new Error('Input must be a valid number');
  }
  return processNumber(userInput);  // Internal: trust the type
}

Connection to Guard Clauses

Fail fast and guard clauses are the same idea from different angles.

Guard clauses are the mechanism: check a condition, return or throw early.

Fail fast is the philosophy: don't let invalid state propagate.

Javascript
// Guard clauses implementing fail-fast
function processPayment(payment) {
  if (!payment) throw new Error('Payment required');
  if (payment.amount <= 0) throw new Error('Amount must be positive');
  if (!payment.currency) throw new Error('Currency required');
  
  // All invalid cases eliminated. Proceed with valid payment.
  return charge(payment);
}

Every guard clause is a fail-fast check. You're saying: "If this precondition isn't met, stop now. Don't continue with invalid state."

Fail Fast in Async Code

The principle applies to async flows too. Validate before starting expensive operations:

Typescript
async function exportReport(params: ExportParams) {
  // Fail fast before hitting the database
  if (!params.format || !['csv', 'pdf'].includes(params.format)) {
    throw new Error('Format must be csv or pdf');
  }
  if (!params.dateRange?.start || !params.dateRange?.end) {
    throw new Error('Date range required');
  }
  
  const data = await fetchReportData(params.dateRange);
  return formatReport(data, params.format);
}

Don't fetch gigabytes of data only to fail on format validation.

Development vs. Production

In development, fail fast with maximum noise. Show stack traces. Log everything. Make bugs obvious.

In production, you still fail fast—but you might handle the failure differently. Log to a service. Show a user-friendly message. Trigger alerts. The key: you still fail. You don't swallow the error or continue with bad state.

Typescript
// Production: fail fast, but handle gracefully
async function processRequest(req: Request) {
  try {
    const order = await parseAndValidateOrder(req.body);
    return await orderService.process(order);
  } catch (error) {
    logger.error('Order processing failed', { error, requestId: req.id });
    throw new HttpError(400, 'Unable to process order. Please check your input.');
  }
}

You fail. You log. You return a clean error to the user. You don't pretend it didn't happen.


Key insight: Fail fast means detecting errors at the earliest point and stopping immediately. Validate at system boundaries. Use assertions for internal invariants. Guard clauses are the mechanism; fail fast is the philosophy. Balance defensiveness: validate where untrusted data enters, but don't add redundant checks in trusted internal code. The goal is clear errors and no corrupted state.