Skip to content
Chapter 102Lesson 2

Comment the why, not the what

The discipline of inline code comments, writing only the why a reader cannot see and promoting it into compiler-enforced structure when you can.

A developer opens a function to clean it up. Partway down, sitting on its own line, is this:

await new Promise((r) => setTimeout(r, 50));

No comment. It looks like cruft, a leftover sleep someone forgot to delete, the kind of thing a tidy-up is for. So they delete it. The tests pass. The PR merges. A week later a flaky race they’ve never seen starts showing up in production, intermittently and only under load, and it takes two engineers half a day to trace it back to that deleted line. The sleep was doing real work: it spaced out two calls that raced otherwise. It needed a why, and because it didn’t have one, nobody knew not to remove it.

Same developer, different file, finds this:

// loop over the invoices
for (const invoice of invoices) {

This one should be deleted. The comment says exactly what the line below it says, in English instead of code. It cost a line to write, it costs a line to read, and it carries nothing.

Those two failures are the whole lesson, and most codebases commit both at once. Junior code over-comments, with lines like // increment i above i++, and then a reactionary “self-documenting code” creed swings the other way and strips comments that carried real, irreplaceable context, like that sleep. The rule you’re learning sits between them: a comment exists to answer a why the code physically cannot, and nothing else.

This is the same judgment you made last chapter, one level down. There the question was which declarations earn a doc block: the contract, read by a caller hovering at the call site. Here it’s which lines earn an inline comment: the reasoning, read by someone working inside the file. The instinct is the same, that volume should track value; only the surface differs, the contract you hover from outside versus the reasoning you read from inside.

This lesson covers three things. First, the one question you’ll run on every comment. Then the four kinds of comment that pass it and the cases that don’t. Last, the reflex that makes this matter more than style: a comment is part of the code, so it either travels with the lines it explains through every refactor or it gets promoted into something the type system enforces.

The whole lesson collapses into one question you run on any line. It has two parts, and a comment earns its place only when the answer to both is yes:

  1. Would a reasonable reader, looking at the code alone, ask “why is it written this way?”
  2. Is the answer invisible in the code, meaning it requires knowledge that lives outside this file?

That second part is the one that does the work. The knowledge a real comment carries always comes from somewhere the reader can’t reach by scrolling: a Postgres quirk, a Stripe API gotcha, a deliberate ordering, a bug that the workaround prevents. The comment is the only place that knowledge can sit next to the line it constrains. If the reader can answer “why is it written this way?” just by reading the line, the comment is noise.

This is also why the what never qualifies. The what is, by definition, already in the file: it is the code. A comment that restates it can’t be carrying anything from outside, because there’s nothing outside to carry. // increment counter above counter++ is the inline twin of a doc block on a private helper, and it does the same harm, because noise dilutes the comments that do carry context. The goal was never volume; it’s signal per line.

Here’s the test firing on a pair that looks almost identical. The line of code is the same in both tabs, and the only difference is where the answer lives.

// add one to the retry count
retries += 1;

The reader answered “why is it written this way?” the instant they read the line. The comment restates the code in English. Nothing here comes from outside the file, so it carries no information that isn’t already on the next line.

The pair is near-identical on purpose. The most common way people get comments wrong is judging them by length, or by whether a line “looks complicated,” and both tabs come out the same under that judgment. The only thing that separates them is the question: does the answer live in the code, or outside it? That’s the judgment to build.

“Knowledge from outside the file” sounds abstract until you’ve seen the shapes it takes. In a SaaS codebase it shows up as four recurring kinds of comment. Learn to recognize them and you’ve learned what a comment should be.

The block below is one Server Action that finalizes an invoice, and it happens to hit all four. Step through it: each step is one kind. As you read, keep one thing in mind. A real function rarely needs all four at once. This one is a teaching specimen, and its density is itself a signal we’ll come back to at the end.

export const finalizeInvoice = authedAction(
'member',
finalizeInvoiceSchema,
async ({ input, orgId }) => {
// Postgres truncates timestamptz to microseconds; round here first or
// the equality check in listDueInvoices misses rows by sub-µs drift.
const finalizedAt = roundToMicros(Temporal.Now.instant());
// Stripe's payment_intent.succeeded can arrive before our finalize commits;
// dedup on payment_intent_id, not arrival order.
const charge = await chargeInvoice(input.invoiceId, { idempotent: true });
// Not using listInvoiceLines() here — its join doubles the query-plan cost
// on tenant_invoices; the narrow select is deliberate.
const lines = await db.query.invoiceLines.findMany({
where: eq(invoiceLines.invoiceId, input.invoiceId),
});
// Order matters: the audit row must commit before the receipt enqueues, or
// a crash between the two loses the audit but still sends the email.
await writeAuditRow({ orgId, action: 'invoice.finalized', charge });
await enqueueReceiptEmail(input.invoiceId);
return ok({ finalizedAt, lines });
},
);

The constraint comment documents external reality the code must bend to. Postgres truncates timestamptz to microseconds, and without the round, the equality check in listDueInvoices silently misses rows. The constraint lives in Postgres, not this file, so roundToMicros looks arbitrary without it. Gone: someone deletes the round as redundant and due-invoice queries start dropping rows nobody can reproduce.

export const finalizeInvoice = authedAction(
'member',
finalizeInvoiceSchema,
async ({ input, orgId }) => {
// Postgres truncates timestamptz to microseconds; round here first or
// the equality check in listDueInvoices misses rows by sub-µs drift.
const finalizedAt = roundToMicros(Temporal.Now.instant());
// Stripe's payment_intent.succeeded can arrive before our finalize commits;
// dedup on payment_intent_id, not arrival order.
const charge = await chargeInvoice(input.invoiceId, { idempotent: true });
// Not using listInvoiceLines() here — its join doubles the query-plan cost
// on tenant_invoices; the narrow select is deliberate.
const lines = await db.query.invoiceLines.findMany({
where: eq(invoiceLines.invoiceId, input.invoiceId),
});
// Order matters: the audit row must commit before the receipt enqueues, or
// a crash between the two loses the audit but still sends the email.
await writeAuditRow({ orgId, action: 'invoice.finalized', charge });
await enqueueReceiptEmail(input.invoiceId);
return ok({ finalizedAt, lines });
},
);

The workaround comment names the failure mode the workaround prevents. Stripe events can arrive out of order, so the dedup keys on payment_intent_id rather than arrival time. Gone: a future reader “simplifies” the dedup to use arrival order and reintroduces a double-charge under event reordering.

export const finalizeInvoice = authedAction(
'member',
finalizeInvoiceSchema,
async ({ input, orgId }) => {
// Postgres truncates timestamptz to microseconds; round here first or
// the equality check in listDueInvoices misses rows by sub-µs drift.
const finalizedAt = roundToMicros(Temporal.Now.instant());
// Stripe's payment_intent.succeeded can arrive before our finalize commits;
// dedup on payment_intent_id, not arrival order.
const charge = await chargeInvoice(input.invoiceId, { idempotent: true });
// Not using listInvoiceLines() here — its join doubles the query-plan cost
// on tenant_invoices; the narrow select is deliberate.
const lines = await db.query.invoiceLines.findMany({
where: eq(invoiceLines.invoiceId, input.invoiceId),
});
// Order matters: the audit row must commit before the receipt enqueues, or
// a crash between the two loses the audit but still sends the email.
await writeAuditRow({ orgId, action: 'invoice.finalized', charge });
await enqueueReceiptEmail(input.invoiceId);
return ok({ finalizedAt, lines });
},
);

The intentional-deviation comment names the path not taken and why. There’s a shared listInvoiceLines() helper right there, and this code deliberately skips it because its join is too expensive at this scale. Gone: a reviewer or an agent “tidies” the narrow select into the helper and quietly doubles the query cost on the hottest table.

export const finalizeInvoice = authedAction(
'member',
finalizeInvoiceSchema,
async ({ input, orgId }) => {
// Postgres truncates timestamptz to microseconds; round here first or
// the equality check in listDueInvoices misses rows by sub-µs drift.
const finalizedAt = roundToMicros(Temporal.Now.instant());
// Stripe's payment_intent.succeeded can arrive before our finalize commits;
// dedup on payment_intent_id, not arrival order.
const charge = await chargeInvoice(input.invoiceId, { idempotent: true });
// Not using listInvoiceLines() here — its join doubles the query-plan cost
// on tenant_invoices; the narrow select is deliberate.
const lines = await db.query.invoiceLines.findMany({
where: eq(invoiceLines.invoiceId, input.invoiceId),
});
// Order matters: the audit row must commit before the receipt enqueues, or
// a crash between the two loses the audit but still sends the email.
await writeAuditRow({ orgId, action: 'invoice.finalized', charge });
await enqueueReceiptEmail(input.invoiceId);
return ok({ finalizedAt, lines });
},
);

The load-bearing-weirdness comment documents an ordering that is part of the contract. The audit row must commit before the email enqueues; flip them and a crash loses the audit but still sends the receipt. Gone: a refactor reorders the two awaits for no reason and a rare crash now ships an unaudited receipt. Hold onto this one: it’s the kind most often stripped, and the one we’ll promote into enforcement shortly.

1 / 1

Notice what every one of those four has in common: each names what breaks if the comment is missing. That consequence is the test from the last section, made concrete. The reader can’t infer it from the line, because the knowledge lives outside the file: Postgres’s precision, Stripe’s ordering, the cost of a join, the failure mode of a reordering. That’s what makes each a why and not a what.

These four aren’t a style invented for this lesson, either. They’re the same set a well-run codebase’s conventions allow inline: runtime invariants the reader can’t infer, security and compliance notes, and the reason behind a non-obvious choice that survived review. It’s the same rule, written down for the whole team to follow.

Knowing a line earns a comment is half the skill. The other half is writing it, and that has its own small discipline, because the reader has already read the code. They don’t need it narrated back to them. The comment adds only the part the code can’t show, in as few words as that takes.

Three rules carry it:

  • One line, direct, declarative. Name the external fact and the action it forces.
  • No hedging, no apology, no narrating the code’s structure.
  • The shape is constraint + response, not a description of a situation.

That third rule is where most comments go soft. Compare the same Stripe-ordering note written two ways:

// We need to handle the case where Stripe events might arrive in an
// unexpected order, so we should make sure we dedupe them somehow.
await recordEvent(event.id);

This narrates a situation and hedges instead of stating the fact and the response. “We need to handle the case where…” is throat-clearing, and “somehow” is a confession that the writer hadn’t decided yet. Three lines that leave the reader knowing less than the crisp version does.

The crisp version isn’t shorter for the sake of brevity. It’s shorter because it dropped everything that wasn’t carrying weight. Strip the throat-clearing and the hedging and what’s left is the actual content: the fact, and the response.

A few small conventions round out the posture. They’re not worth a section of their own, but they compound across a codebase:

The rule only holds if the no cases are as concrete as the yes cases. Each of these has a reason, and the reason is usually that some other tool already solves the problem the comment is reaching for.

  • Restating the code. // loop over invoices above for (const invoice of invoices). The code already says it. The comment is a second copy in a worse language.
  • Section dividers. // === HELPERS ===. If a file is long enough to need internal signposts, the fix is to split the file, not to label its regions. The divider treats the symptom.
  • Author and date stamps. // Created by Maria 2024-03-15. git blame already knows who wrote every line and when, and it never goes stale. The comment rots the first time someone else edits the line.
  • Commented-out code. A block someone disabled “in case we need it later.” You won’t. It’s dead weight the next reader has to puzzle over, and git log is the recovery tool if it ever turns out you do need it back. Delete it; this one is always safe to remove.
  • Bare TODOs. // TODO: fix this. With no owner and no ticket, it’s a wish, and wishes accumulate forever until the file reads like archaeology. A real TODO carries a ticket (// TODO(SAAS-1234): …) or an owner and a deadline. The default that holds up is to put the work in the issue tracker, and reserve an inline TODO for in-flight work shipping this sprint.
  • Fossil comments. A comment explaining a workaround for a bug that was fixed three years ago. The workaround should have been removed, and the comment with it. Left behind, it does more than waste space: it actively misleads, telling the next reader to fear a problem that no longer exists.

That bare-TODO rule has one wrinkle worth naming, because you’ll see it in this course’s own starter code. The lesson stubs you’ll work from carry markers like // TODO(7.6.3) — implement createInvoice. That isn’t a bare TODO: it has an owner, the lesson that builds the stub, and a deadline, the point when that lesson lands. It’s the same rule, met in a different form.

A couple of these, the fossil comment and the dead block, point at a deeper idea: a comment can lie. The code moves on and the comment doesn’t, and now it’s worse than nothing. For now the fix stops at “delete it.” Catching the lie before it merges is a review-discipline problem, and that’s where the next lesson picks it up.

Now make the call at speed. Sort each comment by whether it earns its place.

Sort each comment by whether it earns its place. A comment earns it only when the why lives outside the code. Drag each item into the bucket it belongs to, then press Check.

Earns its place Answers a why the code can't
Noise — delete it Restates the code or rots
// Postgres truncates timestamptz to microseconds; round first or equality checks miss
// Stripe events can arrive out of order; dedup on event_id, not timestamp
// narrow select on purpose — listInvoiceLines()'s join doubles the query cost here
// audit row must commit before the email enqueue, or a crash loses the audit
// increment i
// === HELPERS ===
// Created by Dev on 2024-08-01
// const oldRate = computeRate(v); (commented-out code)
// TODO: fix later

Sorting is the easy half. Producing the right comment under pressure is the half that matters, so here’s the drill: for each snippet, decide the verdict, and when you keep it, get the voice right.

For each snippet, pick a Verdict. When you choose Rewrite, the Replacement dropdown wakes up — pick the comment that earns its place. Then press Check.

  1. Comment 1
    // increment i
    i += 1;
  2. Comment 2
    // We need to make sure we handle the situation where the two writes
    // could happen in the wrong order and cause problems.
    await writeAuditRow(entry);
    await enqueueEmail(entry.id);
  3. Comment 3
    // JS amounts are IEEE-754 doubles; round to whole cents before compare or 0.1 + 0.2 fails.
    if (Math.round(amount) !== expected) {
  4. Comment 4
    // const taxRate = legacyRate(region); // old way, leaving for reference
    const taxRate = currentRate(region);

Where the why belongs: comment vs. TSDoc vs. ADR

Section titled “Where the why belongs: comment vs. TSDoc vs. ADR”

You now know three places a why can live. Last chapter you wrote rationale on a public surface with TSDoc, and a chapter before that you captured an architectural decision in an ADR . This lesson adds the inline comment. The risk now isn’t writing a bad why; it’s writing a good one in the wrong place. So put all three on one map.

What separates them is who reads it, from where, and at what scope. Run through the three panels below. Each shows the surface, who reads it, the scope, and one line of content that belongs there next to one that doesn’t.

Inline // why
Who reads it
Someone editing this file
From where
Inside the function, on the line
Scope
Local — one line or block
Belongs here
// audit row must commit before the email enqueue A one-line constraint, right next to the code it constrains.
Not here
// Why we chose Drizzle over Prisma Architectural scope — that's an ADR.
One line or block, read from inside the file.

The TSDoc panel restates a rule from last chapter, worth repeating because it’s the most common misplacement: implementation rationale does not go in a doc comment. A caller hovering a function wants its contract, meaning what it does, what it assumes, and what it returns. “We chose this approach because…” is not the contract; it’s reasoning about a line, and reasoning about a line lives inline. What separates the inline comment from the ADR is scope alone, one line versus the whole codebase. “Why is await sleep(50) here?” is a comment. “Why Drizzle?” is an ADR, and it never belongs as a comment on the schema file.

This is the idea that separates this lesson from a style guide, so it gets the most room.

A comment is not decoration sitting beside the code. It is part of the code. When a function gets refactored, whether extracted, renamed, or simplified, the // why comments that explained its lines have to move with those lines. They are not leftovers to sweep up and throw out with the cleanup.

Here is exactly how that goes wrong. Watch the load-bearing ordering comment from the finalize action travel through three releases.

lib/invoices/finalize.ts
// Order matters: the audit row must commit before the receipt enqueues,
// or a crash between the two loses the audit but still sends the email.
await writeAuditRow(entry);
await enqueueReceiptEmail(entry.id);
Before: the ordering comment guards two awaits that must run in this order.
lib/invoices/finalize.ts
// Order matters: the audit row must commit before the receipt enqueues,
// or a crash between the two loses the audit but still sends the email.
await persistInvoiceResult(entry);
The refactor: a tidy-up extracts the two writes and drops the comment as 'obvious'.
lib/invoices/persist.ts
const persistInvoiceResult = async (entry: InvoiceResult) => {
await enqueueReceiptEmail(entry.id);
await writeAuditRow(entry);
};
Three releases later: someone reorders the awaits inside the helper. No comment warns them. The audit-loss bug is live again — and harder to find, because the warning is gone.

The bug came back three releases after the warning was deleted, which is the worst possible distance: far enough that nobody connects the cleanup PR to the failure, close enough that the code still looks reasonable. The comment was the only thing standing between “two awaits in an order that matters” and “two awaits in any order,” and a tidy-up erased it without understanding what it was for.

So here is the reflex, as a procedure you run whenever a refactor would delete a comment:

  1. Stop, and understand what the comment was preventing.
  2. Then either carry the comment with the code it explains, or replace it with structural enforcement that makes the bug hard to write.

That second branch is the upgrade, and it rests on the key idea of this section: a comment is the cheapest and weakest form of structural enforcement. It documents that a constraint exists. It does nothing to stop you from violating it. It’s the fallback you reach for when the type system can’t express the rule, and sometimes it can.

Promote the comment to enforcement when you can

Section titled “Promote the comment to enforcement when you can”

When a comment is doing real work, preventing a real bug, the move that pays off isn’t to write it more carefully. It’s to ask: can I move this constraint from prose into something the compiler or the runtime enforces? The comment is the bridge between “the constraint exists” and “the constraint is enforced.” Don’t leave it as prose forever if you can promote it.

Three promotions cover almost every case, and each one builds on something you already wrote earlier in the course:

  • An ordering constraint becomes a transactional function. // audit must commit before the email enqueue becomes a single function that does both inside a transaction, in the guaranteed order. The ordering is now structural, since there’s no longer a way to write the awaits in the wrong sequence, and the comment dissolves. This is the thin-action, pure-lib shape you already use.
  • A “remember to validate” becomes a Zod parse. // validate the input first becomes a safeParse at the boundary. The validation is now enforced rather than remembered, exactly the parse-on-entry discipline, seen from a new angle. The comment was a reminder; the parse is a guarantee.
  • A “must be called after auth” becomes a typed argument. // only call this with an authenticated user becomes a function that takes an authenticated-session type as its argument, or runs behind the authedAction wrapper. Now skipping auth is a compile error, not an honor-system comment.

Here’s one of them end to end. The same logic, first leaning on a comment, then leaning on the compiler.

// Remember to validate input before calling this.
export const createInvoice = async (input: unknown) => {
return db.insert(invoices).values(input as NewInvoice);
};

The constraint lives in prose, and nothing stops a caller from skipping it. input is unknown and gets cast straight into the insert. The comment is a hope, not a guarantee: one forgetful call site and unvalidated data reaches the database.

Not every comment can make that jump, and that’s the judgment this section turns on. Take a Postgres-precision constraint or a Stripe out-of-order fact: no type can express an external system’s behavior, so those stay comments, well-written ones. Promotion is the right call when the constraint is promotable, and recognizing the ones that aren’t, then leaving them as clean prose, is the same skill. So the call is: which of these is asking to become code, and which has to stay a comment?

Which of these comments should be promoted out of prose and into something the compiler or runtime enforces? Pick every one that can be — leave the rest as well-written comments.

// callers must run safeParse on the body before this touches the DB
// don't reorder: the audit insert has to land before we enqueue the email
// never reach this path unless the request already passed the auth check
// timestamps come back rounded to the microsecond, so compare rounded values
// the same webhook can land twice and in either order

CodeAesthetic’s six-minute essay makes the same argument from the other end: most comments are a missed chance to refactor the code or lift the constraint into a type, and the few that survive are the whys no type can express.

One last heuristic, and it closes a loop you may have already noticed.

If a function needs three or more // why comments to be understood, the function is probably the problem: it’s doing too much, or it’s sitting on the wrong abstraction. A single comment signals that something is non-obvious. The density of comments signals where the design has room to improve. So when you reach for the third comment in one function, treat it as a prompt: before you write it, ask whether the function wants splitting or the abstraction wants shifting.

That’s the honest thing to say about the finalizeInvoice specimen from earlier. It carried all four kinds of comment in one function, useful for showing the four shapes side by side, but in real code that density is exactly the smell. A function reaching for four whys probably wants to be two or three smaller functions, each with at most one comment or none. The teaching version traded realism for compactness, and now you can see the cost.

That’s the whole lesson, and it collapses into one reusable check, run at two moments. Before you write a comment, ask whether the why lives outside the code; if it doesn’t, the code already said it. After you write it, ask whether it should be code instead, whether the compiler could carry the constraint you just wrote in prose. Pass both and the comment has earned its line.

Two essays take this principle further than a rule can. The first is the canonical statement of “code tells you how, comments tell you why.” The second is a chapter-length treatment of comments as the place to record the intent the code can’t.