$ emrebener
home topics software development writing readable code

Writing Readable Code

author: emre bener read time: 7 min about: programming style

Reading code is most of what programming actually is. You’ll spend more time loading someone else’s function back into your head than you ever did writing it the first time, and you will do that to your own code six months later. The seven rules below are the ones I keep coming back to when I want code to stay easy to read after the context that produced it has gone cold. None are clever. Each one quietly compounds, and so does the cost of skipping it.

1. Avoid deep nesting

Deeply nested code is hard to read because it overloads your working memory. Every level of indentation is a context you have to track: this branch is true, that one is false, this field exists, that flag is set. By the time you reach the actual work, you’re juggling half a dozen open conditions in your head.

function submitComment(user, comment) {
  if (user) {
    if (user.isLoggedIn) {
      if (comment) {
        if (comment.text && comment.text.trim().length > 0) {
          if (!user.isBanned) {
            if (user.email) {
              saveComment({
                userId: user.id,
                text: comment.text.trim(),
              });
              notifyUser(user.email, "Comment posted!");
            }
          }
        }
      }
    }
  }
}

The standard fix is conditional inversion: flip each condition into a guard clause that exits early. For example:

function sendEmail(user) {
  if (user) {
    if (user.isActive) {
      if (user.email) {
        send(user.email);
      }
    }
  }
}

would become:

function sendEmail(user) {
  if (!user) return;
  if (!user.isActive) return;
  if (!user.email) return;

  send(user.email);
}

This is much easier to read because:

  • Each failure case exits immediately, so you don’t have to carry it as you read on.
  • The happy path is left-aligned.
  • You read top to bottom, not inside → inside → inside.

Nested: pyramid of conditionsGuard clauses: flatif (user)if (user.isLoggedIn)if (comment)if (!user.isBanned)if (user.email)do workif (!user) returnif (!user.isLoggedIn) returnif (!comment) returnif (user.isBanned) returnif (!user.email) returndo workhappy pathNested: pyramid of conditionsGuard clauses: flatif (user)if (user.isLoggedIn)if (comment)if (!user.isBanned)if (user.email)do workif (!user) returnif (!user.isLoggedIn) returnif (!comment) returnif (user.isBanned) returnif (!user.email) returndo workhappy path

2. Extract for clarity

Extraction” is taking a coherent piece of logic and moving it into a well-named unit (function, component, hook) so the original code reads more directly.

For example:

if (
  user &&
  user.isLoggedIn &&
  !user.isBanned &&
  user.email
) {
  sendEmail(user.email);
}

becomes:

if (canReceiveEmail(user)) {
  sendEmail(user.email);
}

function canReceiveEmail(user) {
  return (
    user &&
    user.isLoggedIn &&
    !user.isBanned &&
    user.email
  );
}

A common misconception is that extraction isn’t worth it if the logic is only used once. Extraction is often about clarity, not reuse. Reusability is a nice side effect, not the goal.

2.1. Don’t abstract too early

There’s a flip side. Extracting for clarity is good; extracting for imagined reuse is not. An abstraction encodes assumptions about how its callers behave, and when the second real caller arrives with different assumptions you end up bending the abstraction to fit or duplicating around it. Both are worse than the duplication you would have had if you’d waited.

A reasonable heuristic is the rule of three: let logic repeat twice before extracting on the third occurrence. By then you’ve seen actual variation between the uses, and the shape of the abstraction is forced by data instead of guessed at.

Duplication is far cheaper than the wrong abstraction — Sandi Metz

If a section reads more clearly after extraction, extract it. If you’re doing it only because the logic might be reused later, don’t.

3. Avoid code duplication

When the same logic lives in two places, it drifts. One copy gets a bug fix; the other doesn’t. One picks up a new edge case the other forgets about. Six months later there are three subtly different versions of the same idea and nobody is sure which one to trust. Don’t Repeat Yourself (DRY) is the rule that prevents this: every piece of behaviour should have one authoritative implementation.

For example, if an “is this comment empty?” check lives in three different files:

// submitComment.js
if (!comment || !comment.text || comment.text.trim().length === 0) {
  return;
}

// editComment.js
if (!comment || !comment.text || comment.text.trim().length === 0) {
  throw new Error("Empty comment");
}

// previewComment.js
if (!comment || !comment.text || comment.text.trim().length === 0) {
  return null;
}

The check is one concept. Pull it out:

function isCommentEmpty(comment) {
  return !comment || !comment.text || comment.text.trim().length === 0;
}

Now a change like “comments must be at least 3 characters” or “trim leading whitespace before checking” is a one-line edit in one file, and every caller picks it up.

This pairs naturally with extraction: deduplication is one of the cleanest reasons to pull logic into a named function. The rule of three still applies though. Wait for the third occurrence before extracting, so the shared shape is forced by real variation between callers rather than imagined variation.

4. Name things well

Names are the smallest unit of code that matters. They’re what your eye lands on first when scanning a file, and they tell you whether to trust a function from its signature or dig into the body to find out what it actually does.

There’s no one true naming standard. Pick one, follow it, and make sure each name carries meaning to a reader who hasn’t seen the code before. That alone is the difference between:

function fn(a, b) {
  if (a && b) {
    if (b.x > 2 && !a.y) {
      return b.x * 0.15;
    }
  }
  return 0;
}

and:

function calculateDiscount(user, order) {
  if (!user || !order) return 0;

  const hasEnoughItems = order.itemCount > 2;
  const isFirstPurchase = !user.hasPurchasedBefore;

  if (hasEnoughItems && isFirstPurchase) {
    return order.itemCount * DISCOUNT_RATE;
  }

  return 0;
}

const DISCOUNT_RATE = 0.15;

Same logic, completely different reading experience. The second version tells you what it does without you having to execute it in your head. The intermediate names hasEnoughItems and isFirstPurchase are doing real work. They’re local variables, but they read as assertions, and the if reads as an English sentence.

Nobody wants to read minified code. Name things well.

5. Comments explain why, not what

Code says what; comments say why. If your function names and structure are doing their job, the reader already knows what the code does. Save the comment budget for whatever the code can’t say: the constraint that forced a decision, the tradeoff considered, the bug being worked around, or the assumption you’re making about a caller.

A bad comment:

// Loop through each user
for (const user of users) {
  user.lastSeen = Date.now();
}

The reader can see the loop. The comment adds nothing. Worse, the next time somebody refactors this into users.forEach(...), the comment becomes a minor lie that nobody will notice.

A good comment:

// Stripe's webhook may fire twice for the same charge, so we
// dedupe by event.id rather than charge.id. See: STRIPE-INC-432.
const seen = await dedupeStore.has(event.id);
if (seen) return;

This earns its keep. The next reader, asking “why are we keying off event.id and not charge.id?”, gets the answer in one place instead of spelunking through the issue tracker.

A useful test: if you delete the comment, does the code lose information? If yes, keep it. If the code still tells the same story, the comment was filler.

Stale comments are worse than no comments. They give the reader bad information confidently. Every comment you write is a small promise to keep it true as the code changes. Write fewer of them, and keep them honest.

6. Be consistent

A codebase’s existing conventions are part of its readability. The reader has built a mental model from every other file in the repo: error-handling shape, naming patterns, where files go, how endpoints are wired. A “locally better” choice that breaks the pattern taxes every reader who arrives with that model already loaded.

For example, in a project where every API handler returns { ok: true, data } on success:

// established pattern, used by 30 handlers
function getUser(req, res) {
  const user = userService.get(req.params.id);
  return res.json({ ok: true, data: user });
}

// a new handler that "improves" the response shape
function getProduct(req, res) {
  const product = productService.get(req.params.id);
  return res.json(product);
}

The second handler is arguably cleaner. It also forces every caller of the API to remember which endpoints wrap their payload and which don’t, every reviewer to relearn the convention for this one file, and every future debugger to wonder whether the inconsistency is on purpose. The “improvement” loses to the cost of being different.

The right move when a convention is bad is to change it everywhere, not opt out in one spot. Conventions are cheap when uniform and expensive when not. If you hate the project’s existing pattern, open a PR that converts it project-wide and updates the linter rules. Writing your one file the “right” way and leaving the rest of the codebase to drift around it is the worst of both worlds.

Two practical levers:

  • Push conventions into tooling. Formatters, linter rules, type-checker config. Anything you can encode is one less thing the reader has to remember and one less thing a reviewer has to nag about.
  • When tooling can’t catch it, write it down. A short conventions file beats a long onboarding conversation.

Consistency often beats being “right.” A codebase where every file is the same kind of weird is more readable than one where every file picks its own kind of beauty.

7. Fail loudly and early

Surface failures at their source. Throw close to where things actually went wrong, with a message that names the broken invariant. The principle is called fail-fast, and it’s one of the cheaper debugging tools you’ll find.

The alternative is bad data quietly propagating downstream. By the time you see the symptom (a wrong number on a dashboard, an undefined is not a function three modules deep), the original cause is somewhere upstream and you have no thread to pull on.

A common shape that makes this worse:

function getUserOrders(userId) {
  try {
    const user = userService.get(userId);
    return orderService.byUser(user.id);
  } catch (e) {
    return [];
  }
}

This looks defensive. It’s actually hostile. If userService.get throws because the user ID is malformed, the caller has no idea — they just see “this user has no orders,” and the team spends an afternoon trying to figure out where the orders went.

Better:

function getUserOrders(userId) {
  if (typeof userId !== "string") {
    throw new TypeError(`getUserOrders: userId must be a string, got ${typeof userId}`);
  }
  const user = userService.get(userId);
  return orderService.byUser(user.id);
}

Now the bad input fails immediately, the error names the violated assumption, and the stack trace points at the caller that broke the contract.

Silent swallowFail-fastbad userIdgetUserOrders catches -> []caller sees []dashboard: wrong numbercause is swallowed at the function; symptom shows 3 hops downstreambad userIdgetUserOrders throws TypeErrorstack trace points backcause and symptom collide at the function boundarySilent swallowFail-fastbad userIdgetUserOrders catches -> []caller sees []dashboard: wrong numbercause is swallowed at the function; symptom shows 3 hops downstreambad userIdgetUserOrders throws TypeErrorstack trace points backcause and symptom collide at the function boundary

A working principle: handle expected boundary conditions (a user submitting an empty form field, an HTTP 404 from an external API), but throw on broken internal invariants (this argument should never be null at this point). The first is part of the function’s job. The second is a bug, and silently returning a default just hides it.

Two related habits:

  • Validate at the boundary, not in the middle. Once data has crossed into your code’s trust zone, downstream functions can rely on its shape. Re-validating everywhere is noise.
  • Make error messages specific. throw new Error("invalid") is barely better than no error. Name the function, the violated assumption, and what the caller actually passed.

Loud failures during development are a feature. Silent degradation in production is the bug they prevent.