Deeply nested if statements are one of the most common readability killers. Guard clauses flip the logic: handle the exceptional cases early with returns, leaving the happy path flat and clear.

The Pyramid of Doom

// ❌ Arrow code — the deeper you go, the harder it is to follow
async function processOrder(orderId: string, userId: string) {
  const user = await findUser(userId);
  if (user) {
    if (user.isActive) {
      const order = await findOrder(orderId);
      if (order) {
        if (order.userId === userId) {
          if (order.status === 'pending') {
            if (order.items.length > 0) {
              const payment = await chargeUser(user, order.total);
              if (payment.success) {
                order.status = 'confirmed';
                await saveOrder(order);
                return { success: true, order };
              } else {
                return { success: false, error: 'Payment failed' };
              }
            } else {
              return { success: false, error: 'Empty order' };
            }
          } else {
            return { success: false, error: 'Order not pending' };
          }
        } else {
          return { success: false, error: 'Not your order' };
        }
      } else {
        return { success: false, error: 'Order not found' };
      }
    } else {
      return { success: false, error: 'User inactive' };
    }
  } else {
    return { success: false, error: 'User not found' };
  }
}

With Guard Clauses

// ✅ Flat, readable, obvious flow
async function processOrder(orderId: string, userId: string) {
  const user = await findUser(userId);
  if (!user) return { success: false, error: 'User not found' };
  if (!user.isActive) return { success: false, error: 'User inactive' };

  const order = await findOrder(orderId);
  if (!order) return { success: false, error: 'Order not found' };
  if (order.userId !== userId) return { success: false, error: 'Not your order' };
  if (order.status !== 'pending') return { success: false, error: 'Order not pending' };
  if (order.items.length === 0) return { success: false, error: 'Empty order' };

  const payment = await chargeUser(user, order.total);
  if (!payment.success) return { success: false, error: 'Payment failed' };

  order.status = 'confirmed';
  await saveOrder(order);
  return { success: true, order };
}

The happy path reads top to bottom without indentation. Every guard clause handles one concern and exits immediately.

With Custom Errors (Even Cleaner)

// ✅ Even cleaner with exceptions for control flow
async function processOrder(orderId: string, userId: string): Promise<Order> {
  const user = await findUser(userId);
  if (!user) throw new NotFoundError('User');
  if (!user.isActive) throw new ForbiddenError('User account is inactive');

  const order = await findOrder(orderId);
  if (!order) throw new NotFoundError('Order');
  if (order.userId !== userId) throw new ForbiddenError('Order belongs to another user');
  if (order.status !== 'pending') throw new ConflictError('Order is not pending');
  if (order.items.length === 0) throw new ValidationError('Order has no items');

  const payment = await chargeUser(user, order.total);
  if (!payment.success) throw new PaymentError('Payment declined');

  order.status = 'confirmed';
  await saveOrder(order);
  return order;
}

Guard Clause Patterns

Assert-Style Guards

function assert(condition: boolean, message: string): asserts condition {
  if (!condition) throw new Error(message);
}

function calculateDiscount(price: number, percentage: number): number {
  assert(price > 0, 'Price must be positive');
  assert(percentage >= 0 && percentage <= 100, 'Percentage must be 0-100');
  
  return price * (percentage / 100);
}

TypeScript Narrowing Guards

function processResponse(response: ApiResponse) {
  if (response.status === 'error') {
    logError(response.error);
    return;
  }
  
  // TypeScript now knows response.status === 'success'
  // and response.data exists
  renderData(response.data);
}

Validation Guard Functions

function ensureAuthenticated(req: Request): asserts req is AuthenticatedRequest {
  if (!req.user) throw new UnauthorizedError('Authentication required');
}

function ensureAdmin(req: AuthenticatedRequest): void {
  if (req.user.role !== 'admin') throw new ForbiddenError('Admin access required');
}

async function deleteUser(req: Request) {
  ensureAuthenticated(req);
  ensureAdmin(req);
  
  // From here: req.user exists AND is admin
  await userRepo.delete(req.params.userId);
}

Rules

  1. Guard against the exceptional case, not the happy case — check for errors, not success
  2. Put the most likely failures first — fail fast
  3. One guard per line — don’t combine multiple checks
  4. Use consistent patterns — pick a style (return vs throw) and stick with it
  5. Name your errors'Not found' beats 'Error'

“Failing fast is an important concept in software design. A system that fails fast does less damage and is easier to debug.” — Jim Shore