Skip to content

Instantly share code, notes, and snippets.

@mpaleo
Created June 16, 2026 04:28
Show Gist options
  • Select an option

  • Save mpaleo/32b5bf3dc40c73e45cfa63b17ed95ed0 to your computer and use it in GitHub Desktop.

Select an option

Save mpaleo/32b5bf3dc40c73e45cfa63b17ed95ed0 to your computer and use it in GitHub Desktop.

Cognitive Complexity: Optimize for the Reader, Not the Linter

I've reviewed code that passed every check we had. Functions were short, lint was clean, coverage was north of ninety percent. And I still had to read it four times to understand what it did. The metrics were green and the code was a fog. That gap, between code that scores well and code a human can actually follow, is the whole reason cognitive complexity is worth talking about.

What the metric actually measures

Most of us learned cyclomatic complexity first, which counts the number of independent paths through a piece of code. It's fine for knowing how many test cases you need, but it's a poor model of how hard code is to read. A flat switch with twelve cases has high cyclomatic complexity and is trivial to understand. Three levels of nested conditionals have fewer paths and are miserable.

Cognitive complexity, introduced by SonarSource and largely credited to G. Ann Campbell, tries to measure the second thing: how much effort a person spends following the code. The rules that make it up are basically a catalogue of what taxes a reader. Nesting costs more the deeper you go, so each level you add is worse than the last. Breaks in linear flow cost extra, a jump, a continue, a long chained condition. A straight sequence you can read top to bottom is cheap, even when it's long.

The reason that's a better model is that it lines up with the actual bottleneck, which was never the number of paths through the code. It's your head.

The real cost is working memory

Here's how I think about it. The cost of a line is how many things you have to hold true at the same time to understand it. Nesting is expensive because each level adds another condition you keep loaded while you read what's inside. Mutable state threaded through a long function is expensive because the value depends on everything above it, so you can't understand line 40 without having tracked lines 1 through 39. Action at a distance, a flag whose meaning flips halfway down, a variable reassigned in three branches: it's all the same tax. You're being asked to keep too much in your head at once.

When you cut nesting, you're usually cutting exactly that. Compare these:

function getDiscount(user: User, cart: Cart): number {
  let discount = 0;
  if (user.isActive) {
    if (cart.items.length > 0) {
      if (user.tier === 'premium') {
        if (cart.total > 100) {
          discount = 0.2;
        } else {
          discount = 0.1;
        }
      } else {
        if (cart.total > 100) {
          discount = 0.05;
        }
      }
    }
  }
  return discount;
}

To understand the bottom of that you're holding four conditions and a mutable discount that might have been set in any of them. Now the same logic with guard clauses:

function getDiscount(user: User, cart: Cart): number {
  if (!user.isActive) return 0;
  if (cart.items.length === 0) return 0;

  if (cart.total <= 100) {
    return user.tier === 'premium' ? 0.1 : 0;
  }
  return user.tier === 'premium' ? 0.2 : 0.05;
}

Each line is a complete thought you can discharge and forget. You knock out the disqualifying cases and they leave your head. By the time you reach the bottom you're holding one condition instead of four, and there's no accumulating variable to track. This is the case everyone agrees on: lower nesting, lower score, easier to read, all pointing the same way.

The trouble starts when they stop pointing the same way.

When the number becomes the target

Give a team a complexity threshold and a way to measure it, and some of the code will start optimizing for the measurement instead of the reader. The classic move is to take one coherent function that scores a little high and shred it into fragments that each score low:

function getDiscount(user: User, cart: Cart): number {
  return isEligible(user, cart) ? computeRate(user, cart) : 0;
}

function isEligible(user: User, cart: Cart): boolean {
  return user.isActive && hasItems(cart);
}

function hasItems(cart: Cart): boolean {
  return cart.items.length > 0;
}

function computeRate(user: User, cart: Cart): number {
  return isLargeOrder(cart)
    ? premiumOrStandard(user, 0.2, 0.05)
    : premiumOrStandard(user, 0.1, 0);
}

// ...isLargeOrder, premiumOrStandard, and the rest

Every function here scores beautifully. And to understand the discount logic you now have to open six of them, hold the call graph in your head, and reassemble in the right order what the guard-clause version told you in nine lines you could read straight down. The metric went down. The effort to actually understand it went up. We moved the complexity out of any single function and into the gaps between them, where no linter measures it and every reader pays for it.

This is the same mistake as merging two functions because they happen to look alike. It's optimizing the thing you can see, a number, a duplicated block, instead of the thing that matters, whether the next person can follow it.

Extraction is a tool, not a virtue

None of this means small functions are bad. Extraction is one of the best tools we have. It earns its keep when the piece you pull out is a real concept the reader can trust by its name and not open: calculateTax, isWithinBusinessHours, parseChunkHeader. A good extraction removes things from the reader's head, because they read the call site, believe the name, and move on. A bad extraction just relocates the things, because the name doesn't stand on its own and you have to open it anyway to find out what's really going on.

So the question I ask isn't "is this function short enough." It's "does pulling this out reduce what the reader has to hold, or just move it somewhere the metric can't see." For a leaf concept with a name that tells the truth, extracting helps. For an arbitrary slice of a sequence, it hurts, and a flatter, slightly longer function you can read in one pass is the better call.

What I optimize for

In practice it comes down to a few habits. Flatten before you split, because most "complex" functions are just deeply nested, and guard clauses or inverting a condition fix the load without adding a single new function. Kill state that spans a long scope, because a value you have to carry from the top is a tax on every line beneath it. Name the thing the reader is following. And extract only when the result is a name they can trust without opening it.

The test I actually use is simple. Can someone new read this top to bottom and keep up, without scrolling to five other files to find out what's really happening. If yes, the complexity is low in the only place it counts. If they have to jump around and rebuild it from memory, it doesn't matter what the number says.

And now there's a second reader

Everything above I could have written five years ago, and it would have been entirely about people. It still mostly is. But there's a second reader on almost every codebase now, the model in your editor, and the worthwhile observation is that it trips on the same code we do, for a reason that rhymes. A person has working memory. A model has a context window. Both are finite, and both are the real ceiling on understanding. When code makes you hold the whole call graph in your head to follow a change, it makes the model pull half the repo into context to reason about that same change.

So the symmetry runs all the way through. That function you shredded into six fragments to satisfy a threshold is exactly as annoying to an assistant tracing the discount logic as it is to the engineer doing it by hand: both have to hunt down the pieces, load them, and rebuild the order. Deep nesting with accumulating state is where a model loses the thread for the same reason you do, too many things true at once. And the flip side is the part I like. The work you do to lower cognitive load, flatten the nesting, kill the long-lived state, give things names that can be trusted without opening them, pays off twice. A function a person can read top to bottom is one the model can reason about without dragging in five other files. A name that tells the truth lets both of you treat it as a black box and move on.

It isn't a perfect overlap. A model can hold more raw text at once than you can, and it won't get bored by boilerplate the way you will. But the core of it holds: code that asks the reader to keep less in mind is easier for both kinds of reader, which means optimizing for the human is mostly the same work as optimizing for the tool. That's a good deal, since you were going to do it for the human anyway.

The number is a smell, not a goal

Cognitive complexity is genuinely useful, and more useful than cyclomatic complexity, because it's trying to measure the right thing: how hard code is for a person to understand. Treat a high score as a finger pointing at a spot where a human is likely to struggle, and go look. That's what it's good for.

But the target is always the person, never the number. The moment you optimize the metric directly you get code that's easy to measure and hard to read, which is the exact opposite of the point. The skill, same as it ever was, is keeping your eye on the reader's head and using the number only to find the places worth your attention.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment