Avoiding Deep Nesting
Every level of nesting is a cognitive burden.
When you read nested code, your brain maintains a mental stack: "I'm inside this if, which is inside this loop, which is inside this try block..." By the time you're three or four levels deep, you've exhausted working memory.
The research is clear: deeply nested code has more bugs, takes longer to understand, and is harder to modify safely.
The Mental Stack Problem
Consider this code:
function processItems(items) {
if (items) {
for (const item of items) {
if (item.active) {
if (item.quantity > 0) {
if (item.price) {
for (const discount of applicableDiscounts) {
if (discount.applies(item)) {
// Finally, the actual logic
// What context are we in again?
}
}
}
}
}
}
}
}
To understand the innermost code, you need to remember:
- Items exist
- We're iterating over items
- The current item is active
- The current item has quantity > 0
- The current item has a price
- We're iterating over discounts
- The current discount applies to the item
That's 7 items in your mental stack. Most people can hold about 4.
Maximum Nesting Depth
Rule of thumb: maximum 2-3 levels of nesting.
Beyond that, your code is telling you something needs to be extracted or restructured.
Technique 1: Guard Clauses
We covered this in the previous lesson, but it bears repeating.
Before
function processOrder(order) {
if (order) {
if (order.isValid) {
if (order.isPaid) {
fulfill(order);
}
}
}
}
After
function processOrder(order) {
if (!order) return;
if (!order.isValid) return;
if (!order.isPaid) return;
fulfill(order);
}
Technique 2: Extract Functions
When nested code represents a distinct operation, extract it.
Before
function processOrders(orders) {
const results = [];
for (const order of orders) {
if (order.status === 'pending') {
if (order.hasInventory) {
const total = 0;
for (const item of order.items) {
if (item.inStock) {
total += item.price * item.quantity;
}
}
if (total > 0) {
results.push({ orderId: order.id, total });
}
}
}
}
return results;
}
After
function processOrders(orders) {
return orders
.filter(isPendingWithInventory)
.map(calculateOrderTotal)
.filter(result => result.total > 0);
}
function isPendingWithInventory(order) {
return order.status === 'pending' && order.hasInventory;
}
function calculateOrderTotal(order) {
const total = order.items
.filter(item => item.inStock)
.reduce((sum, item) => sum + item.price * item.quantity, 0);
return { orderId: order.id, total };
}
Each function is flat. The logic is composed.
Technique 3: Use Array Methods
Loops often nest unnecessarily. Functional array methods flatten them.
Before: Nested Loops
const results = [];
for (const category of categories) {
for (const product of category.products) {
if (product.inStock) {
results.push(product.name);
}
}
}
After: Flat Chain
const results = categories
.flatMap(category => category.products)
.filter(product => product.inStock)
.map(product => product.name);
Technique 4: Invert Conditions
Sometimes inverting a condition lets you return early.
Before
function handleResponse(response) {
if (response.ok) {
const data = parseData(response.body);
if (data.valid) {
return processData(data);
} else {
log('Invalid data');
}
} else {
throw new ResponseError(response.status);
}
}
After
function handleResponse(response) {
if (!response.ok) {
throw new ResponseError(response.status);
}
const data = parseData(response.body);
if (!data.valid) {
log('Invalid data');
return;
}
return processData(data);
}
Technique 5: Use continue in Loops
continue skips to the next iteration, reducing nesting inside loops.
Before
for (const user of users) {
if (user.isActive) {
if (user.hasSubscription) {
if (!user.isBlocked) {
sendNewsletter(user);
}
}
}
}
After
for (const user of users) {
if (!user.isActive) continue;
if (!user.hasSubscription) continue;
if (user.isBlocked) continue;
sendNewsletter(user);
}
Technique 6: Use Early break
For search loops, break early when you find what you need.
Before
function findFirstActive(users) {
let result = null;
for (const user of users) {
if (user.isActive) {
if (result === null) {
result = user;
}
}
}
return result;
}
After
function findFirstActive(users) {
for (const user of users) {
if (user.isActive) {
return user;
}
}
return null;
}
Or just:
function findFirstActive(users) {
return users.find(user => user.isActive) ?? null;
}
Technique 7: Replace Nested Callbacks
Callback hell is a special form of nesting.
Before: Callback Pyramid
getUser(userId, (err, user) => {
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
getShipments(orders, (err, shipments) => {
if (err) return handleError(err);
render(user, orders, shipments);
});
});
});
After: Async/Await
async function loadDashboard(userId) {
const user = await getUser(userId);
const orders = await getOrders(user.id);
const shipments = await getShipments(orders);
render(user, orders, shipments);
}
Measuring Nesting
Tools can measure cyclomatic complexity and nesting depth:
- ESLint's
max-depthrule - SonarQube's cognitive complexity
- Code Climate metrics
Configure them:
{
"rules": {
"max-depth": ["error", 3]
}
}
Now deeply nested code fails linting.
Key insight: Every nesting level is a mental burden. Use guard clauses, extract functions, leverage array methods, and invert conditions to keep code flat. When you can't read the code without losing track of context, it's too nested.