Brian Kernighan wrote the permanent version of this post in 1978, in two sentences:
Everyone knows that debugging is twice as hard as writing a program in the first place. So if you’re as clever as you can be when you write it, how will you ever debug it?
The argument here is narrower and more uncomfortable: functional style invites the violation of Kernighan’s law more seductively than most styles, precisely because of what makes it good. Everything is an expression; expressions compose; composition compresses. The distance between “this pipeline is clear” and “this pipeline is a party trick” is three refactors, each of which felt like an improvement to its author. And the cost lands elsewhere — on the reviewer, the on-call engineer, the next hire — which is what makes it hidden. Nobody budgets for it because the person who incurs it never pays it.
This blog spends most of its time arguing for functional techniques. This post is the ledger’s other column, from the section where opinions live.
What cleverness looks like when it wears a lambda
Cleverness is not density, and it is not abstraction. A map/filter/fold chain your
whole team reads fluently is dense and clear. Cleverness is spending the reader’s
attention on your ingenuity instead of the problem. Three specimens, all from real
Java codebases in spirit:
The tuple smuggled through reduce. One pass, two aggregates, zero names:
BigDecimal[] stats = orders.stream() .map(Order::total) .reduce(new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO}, (acc, t) -> new BigDecimal[]{acc[0].add(t), acc[1].max(t)}, (a, b) -> new BigDecimal[]{a[0].add(b[0]), a[1].max(b[1])});// stats[0] is the sum. stats[1] is the max. You just have to know.It compiles, it runs, it is even efficient — one pass! It is also wrong:
BigDecimal.ZERO is not an identity for max, so a list of all-negative totals — a day
of refunds — reports a maximum of zero, a value that appears nowhere in the data, and an
empty list is indistinguishable from it. The bug sat comfortably inside the cleverness,
which is the point: every reader must first decode the array-as-tuple convention, track
which index means what, and check the combiner against reduce’s identity contract
before they can even see it. The boring alternative has nowhere for it to hide:
BigDecimal sum = orders.stream().map(Order::total).reduce(BigDecimal.ZERO, BigDecimal::add);Optional<BigDecimal> max = orders.stream().map(Order::total).max(Comparator.naturalOrder());Two passes over an in-memory list cost nothing worth discussing at the scale most
services meet, ZERO is a true identity for add, and max’s empty case surfaces as an
honest Optional — while for the sum, zero really is the sum of nothing. The clever
version optimized a machine cost nobody had measured by adding a human cost everybody
pays — and a wrong answer nobody would spot in review. When one pass genuinely earns its
keep — the list is huge, Order::total is expensive, the stream cannot be replayed — the
answer is still not the array: a small record with an explicit empty case states its
meaning, or, for a genuinely anonymous pair inside one private step, the library’s
tuples guide blesses a Tuple2. The named record earns its
letters here because sum and max share a type and will be confused; the guide’s
decision table draws exactly that line.
The domain fact encoded as a type pun. The type system can express “not yet
evaluated” versus “evaluated, nothing applies” by nesting — here with the library’s
Option, though java.util.Optional invites the identical pun:
Option<Option<Discount>> quote; // none = not evaluated; some(none) = no discount appliesThat comment is load-bearing, which is the tell: the encoding means nothing without it. Nested containers are a fact about plumbing; the domain had two named states that deserved names of their own:
sealed interface Quote { record NotEvaluated() implements Quote {} record NoDiscount() implements Quote {} record Discounted(Discount discount) implements Quote {}}Same information, but now the compiler enforces the cases, the switch reads as policy,
and no one needs the comment. The clever version signaled fluency with the container
types; the boring version made the API say what it means.
The pipeline that swallowed the paragraph. Chains of flatMap and fold where each
step is small but the whole has no name anywhere — combinator golf. Each link is
defensible; the chain, read at 2am, requires the reader to run
the substitution model
in their head for eight steps while holding three type parameters. The
worse post already draws the
concrete line — a chain of more than three steps benefits from named intermediates — and
this specimen is what ignoring that line looks like at expert speed. The fix costs one
private method with a domain name per conceptual step — the pipeline shape survives,
the archaeology doesn’t.
Why the cost stays hidden
The economics are asymmetric in three ways, and all three favor the writer:
- The cost is paid at read time, and code is read far more often than written. The clever fold took its author four satisfying minutes. Every future reader pays a decoding tax, and the sum of those taxes exceeds the writing time within weeks.
- The cost lands on other people. Review latency is the first symptom — the PR that sits because reviewing it honestly requires an hour nobody scheduled. Approvals arrive anyway (“looks right, I think”), which converts unread cleverness into unaudited risk.
- The cost compounds silently. The clever module becomes the one nobody volunteers to touch; the team routes around it; the person who wrote it becomes its permanent operator. That is a bus factor of one, self-inflicted, wearing the costume of mastery.
There is also a social engine underneath, worth naming because naming it weakens it: terse functional code signals membership. Point-free style, the maximally general combinator, the one-expression method — these read as fluency to insiders and as a wall to everyone else. When the signal matters more than the reader, the codebase is paying for someone’s identity. It is kin to purism losing to pragmatism in teams that ship — though that post makes the case on technical trade-offs; the social column is this one’s addition.
To be precise about the boundary: this is not the cost of functional abstraction —
learning what a fold is, reading Result pipelines — which is a real but different
ledger, one this blog will take up on its own. Cleverness is the spending you do after
fluency, and it is entirely optional. The
antipatterns post catalogs the shapes that slip
into otherwise careful code by accident; this post is about the narrower subset written
on purpose — cleverness as a choice, made at the keyboard, with the better version in
plain view.
Spending ingenuity where it pays
The prescription is not “dumb it down” — boring code is not unsophisticated code. Predictability is the sophistication. Some working heuristics, offered with opinions attached:
- Name the intermediate thing. A local variable with a domain name is documentation the compiler checks. If a pipeline needs three of them, that is not a failure of functional style; it is the pipeline telling you its structure.
- Prefer two obvious lines to one clever one. You will win the exchange every time someone reads the code — which is to say, every time the code matters.
- If writing it was fun, read it again tomorrow. The fun is a signal: you were solving a puzzle. Confirm the puzzle was the domain’s, not one you smuggled in.
- Let the team’s fluency set the density. An idiom everyone reads at a glance
(
filter/map/toList) is free; an idiom one person reads is a tax on four. This boundary moves as the team learns — push it deliberately in teaching moments, not incidentally in a Friday PR. - Spend cleverness on the domain, not the plumbing. The insight that two pricing states deserved distinct types is cleverness that pays — every reader afterward thinks more clearly. The array-tuple fold is cleverness that costs. Same ingenuity, opposite sign; the difference is whether the reader ends up smarter about the problem or about you.
Kernighan’s law has a constructive corollary: write at half your cleverness and you retain a margin for debugging — and for every future reader who meets the code without you standing next to it. A sailor’s knot is judged at the wrong moment to impress: not when it is tied, admired, and photographed, but when it must be untied — in the dark, in weather, under someone else’s cold hands. The rope in the photo above holds its rail beautifully; the only test that counts is the untying. Tie your code for that moment.
Further reading
- The Elements of Programming Style by Brian Kernighan and P. J. Plauger — the source of the law, still sharp nearly fifty years on.
- A Philosophy of Software Design by John Ousterhout — complexity as the enemy, and “obvious” as a design goal.
- Simple Made Easy by Rich Hickey — the canonical talk on simplicity as an objective property, not a feeling.
- When “Making It Functional” Actually Makes the Code Worse — the failure modes in full, including the three-step threshold this post’s third specimen keeps violating.
- Common Anti-Patterns When Writing Functional Code in Java — the shapes that slip into careful code by accident; this post covers the deliberate subset.
- Pragmatic Functional Programming vs Academic Purism — the technical case for pragmatism over purism; this post adds the social column.
Found a bug or have a suggestion? Open an issue on GitHub.
