Self-documenting code
A comment that explains WHAT the code does is usually a name waiting to be extracted. Move the WHAT into structure — named functions and variables — and keep comments for the WHY code can't carry: rationale, constraints, links.
You write a comment to be helpful: // calculate the discounted total after tax, then twenty lines of arithmetic. Six months later the arithmetic changes but the comment doesn’t, and now the comment lies. A reviewer trusts the comment, skips the code, and approves a bug. The comment that was supposed to help has become a second source of truth that nobody keeps in sync.
Most explanatory comments are like this: they describe what the code does, in prose, right next to code that already says what it does — just less readably. A comment describing the what is almost always a name you haven’t extracted yet. “Self-documenting code” is the discipline of moving that what out of comments and into the structure of the code itself, so the description can’t drift from the thing it describes.
After this lesson you can tell a what-comment (a name waiting to be extracted) from a why-comment (rationale the code can’t express); replace a “compute X” comment by extracting a well-named function or an explaining variable; recognise the failure mode where extracting one-liners purely to delete comments trades a clear block for confusing indirection; and decide which comments deserve to survive a refactor — the ones carrying rationale, constraints, and links the code structurally cannot.
A comment that says what the code does is a name you haven’t written yet. When you find yourself writing // check if the user can edit this post above a tangle of boolean conditions, you have already named the concept — in the comment. The code below it is the unnamed implementation. Extracting that into canEdit(user, post) moves the name from a comment (which the compiler ignores and nobody refactors) into a function signature (which the compiler checks and every caller reads).
// before: the name lives in a comment the compiler can't enforce
// check if the user can edit this post
if (user.id === post.authorId || user.role === "admin") { /* ... */ }
// after: the name lives in code that reads itself
if (canEdit(user, post)) { /* ... */ }The comment and the condition could drift apart; the function name and its body cannot. The what is now carried by structure, not prose.
A dense block annotated by “step” comments is several named steps in disguise. A classic shape is one long function with // step 1: …, // step 2: … comments breaking it into phases. Those comments are an admission that the block does several things and you felt the need to label them. Each label is a function name asking to exist.
// before — comments narrate a block that does too much
function publish(post: Post) {
// validate the post is ready
if (!post.title || !post.body) throw new Error("incomplete");
if (post.body.length < 100) throw new Error("too short");
// render markdown to html and strip scripts
const html = sanitize(renderMarkdown(post.body));
// write to the store and bump the index
store.save({ ...post, html, status: "live" });
searchIndex.add(post.id, post.title);
}
// after — each step comment becomes a name that reads itself
function publish(post: Post) {
assertPublishable(post);
const html = renderSafeHtml(post.body);
persistAndIndex(post, html);
}The second publish reads like the step comments did — except the names are now verified by the type system and reused wherever the same step recurs. The comments became the table of contents the function always wanted.
An explaining variable turns a cryptic expression into a sentence — no function needed. Extraction is not only about functions. When a condition or sub-expression needs a // because … to be legible, naming an intermediate value often carries the meaning more cheaply than a whole function would.
// before: a comment is propping up an unreadable condition
// only retry on transient network failures, not 4xx
if (err.code === "ECONNRESET" || err.code === "ETIMEDOUT" || (err.status >= 500 && err.status < 600)) {
retry();
}
// after: names make the condition state its own intent
const isNetworkReset = err.code === "ECONNRESET" || err.code === "ETIMEDOUT";
const isServerError = err.status >= 500 && err.status < 600;
const isTransient = isNetworkReset || isServerError;
if (isTransient) retry();The comment // only retry on transient failures is now redundant — if (isTransient) retry() says it in code. The explaining variable is the lightest extraction available: no new call, no jump to another file, just a name.
The comments that survive are the WHY — and self-documenting code makes them more visible, not fewer. Once the what is carried by names, the only comments left are the ones code structurally cannot express: why a surprising decision was made, a link to the ticket or RFC that justifies it, a non-obvious constraint, a workaround for a bug in something you don’t control. These are gold, because no amount of renaming can derive them from the code — they encode context that lives outside it.
// WHY survives: the code can't explain itself
// Stripe rounds half-up; we round half-even to match Finance's ledger.
// Changing this silently re-bills every subscription. See FIN-2231.
const cents = bankersRound(amount * 100);
// Safari <16 throws on structuredClone of a Blob; fall back. (bug: WK-204931)
const copy = canStructuredClone ? structuredClone(v) : deepCopy(v);“Self-documenting” does not mean comment-free. It means the code carries the what so clearly that the surviving why-comments stand out instead of drowning in // increment i noise. A senior reviewer reads a file and every remaining comment earns its place.
One messy function, comments and all, refactored to read itself — while keeping the one comment that matters. Here is a fee calculator narrated by step comments, with a genuine why buried among them:
function settlementFee(order: Order): number {
// step 1: base fee is 2.9% + 30c, like the card networks
let fee = order.total * 0.029 + 30;
// step 2: high-risk regions get a surcharge
if (order.region === "BR" || order.region === "NG" || order.region === "ZA") {
fee += order.total * 0.01;
}
// step 3: cap the fee — legal limit in the EU is 1.5% (PSD2), don't exceed
if (order.region.startsWith("EU") && fee > order.total * 0.015) {
fee = order.total * 0.015;
}
return Math.round(fee);
}Three step comments narrate what; one fragment (legal limit in the EU is 1.5% (PSD2)) is a real why. Move the whats into names and let the why survive as the only comment:
function settlementFee(order: Order): number {
const fee = applyRiskSurcharge(baseFee(order), order);
return Math.round(capForRegion(fee, order));
}
function baseFee(order: Order): number {
return order.total * 0.029 + 30; // card-network standard: 2.9% + 30c
}
function applyRiskSurcharge(fee: number, order: Order): number {
return isHighRiskRegion(order.region) ? fee + order.total * 0.01 : fee;
}
function capForRegion(fee: number, order: Order): number {
// PSD2 caps EU interchange at 1.5%; exceeding it is a legal violation, not just a bug. See PAY-884.
if (!order.region.startsWith("EU")) return fee;
return Math.min(fee, order.total * 0.015);
}The “step 1/2/3” comments vanished — they are now function names that read in sequence inside settlementFee. The high-risk region list became isHighRiskRegion, a named predicate reusable elsewhere. And the one comment that couldn’t be a name — the PSD2 legal constraint — not only survived but got sharper: a reader now knows the cap is law, not a tunable, plus a ticket to chase. That is the whole move: structure absorbs the what, the why is left standing alone where it’s impossible to miss.
▸Why this works
Why does moving the what into names beat a good comment, when both read clearly today? Because they decay differently. A name is load-bearing: callers reference it, the type checker validates it, a rename tool updates every use, and the body can’t silently disagree with a signature people call. A comment is inert: nothing references it, nothing checks it, and the next person who edits the code three lines below has no mechanical reason to update the prose three lines above. So a comment and its code drift apart by default, while a name and its body stay coupled by construction. You are not choosing between two equally good descriptions — you are choosing between a description the toolchain keeps honest and one it ignores.
▸Common mistake
The senior failure mode is over-correcting: extracting a one-line function purely to delete a comment, and ending up worse off. // add tax above total + total * rate does not justify a function totalWithTax(...) if it’s called once and the inline form already reads fine — you’ve traded a glanceable line for a jump to another location, an extra name to learn, and a function that exists only to host a comment’s worth of meaning. Indirection has a real cost: every extracted name is something to read, locate, and keep coherent. Extract when the name removes genuine cognitive load (a tangled condition, a repeated step, a what-comment over a real block). Don’t extract when the inline code is already a sentence and the function would just be a thinly-disguised comment with a call-site. Self-documenting code is fewer comments and the right amount of structure — not maximum structure.
You see `// reject orders from sanctioned countries` above a list of country-code checks, and three lines later `// using FNV-1a here because the default hash collided badly on our short keys (see PERF-77)`. Refactoring toward self-documenting code, what do you do with each comment?
A comment that explains what the code does is usually a name waiting to be extracted: // check if the user can edit becomes canEdit(user, post), // step 2 becomes a named function, an unreadable condition becomes a chain of explaining variables. Moving the what into structure beats commenting it because a name is load-bearing and toolchain-checked while a comment is inert and drifts. The comments that survive are the why — rationale, links to tickets and RFCs, non-obvious constraints, surprising decisions — which code structurally cannot express; self-documenting code makes those more visible, not absent. The failure mode is over-extraction: spawning one-line functions purely to delete a comment, trading a glanceable block for indirection that’s harder to follow. The test, as always: does this change make the next reader’s next change cheaper — or just rearrange it?
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.