Guard Clauses Over Nested Ifs
Flatten your code by replacing deeply nested conditionals with early returns. Guard clauses make the happy path obvious and errors impossible to miss.
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
- Guard against the exceptional case, not the happy case — check for errors, not success
- Put the most likely failures first — fail fast
- One guard per line — don’t combine multiple checks
- Use consistent patterns — pick a style (return vs throw) and stick with it
- 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