When inline comments earn their place
The discipline of inline code comments: write only the why a reader cannot see, and promote it into compiler-enforced structure when you can.
A developer opens a function to clean it up. Partway down, on its own line, sits this:
await new Promise((r) => setTimeout(r, 50));No comment. It looks like a leftover sleep someone forgot to delete, so they delete it. The tests pass, the PR merges. A week later a flaky race shows up in production under load, and two engineers spend half a day tracing it to that deleted line. The sleep was real work: it spaced out two calls that raced otherwise. Without a why, nobody knew not to remove it.
Same developer, different file, finds this:
// loop over the invoicesfor (const invoice of invoices) {This one should go. It says exactly what the line below says, in English instead of code, and carries nothing.
Most codebases commit both failures at once. Junior code over-comments (// increment i above i++); then a “self-documenting code” creed overcorrects and strips comments that carried irreplaceable context, like that sleep. The rule sits between them: a comment exists to answer a why the code physically cannot, and nothing else.
This is the same judgment as last chapter, one level down. There it was which declarations earn a doc block, the contract read by a caller at the call site. Here it is which lines earn an inline comment, the reasoning read from inside the file. Same instinct, that volume should track value; only the surface differs.
The why-not-what rule
Section titled “The why-not-what rule”Run one question on any line. It has two parts, and a comment earns its place only when both answers are yes:
- Would a reasonable reader, looking at the code alone, ask “why is it written this way?”
- Is the answer invisible in the code, because it lives outside this file?
The second part does the work. A real comment carries knowledge the reader can’t reach by scrolling: a Postgres quirk, a Stripe API gotcha, a deliberate ordering, a bug the workaround prevents. The comment is the only place that knowledge can sit next to the line it constrains. If the reader can answer the question from the line itself, the comment is noise.
This is why the what never qualifies: it is the code, so a comment that restates it carries nothing from outside. // increment counter above counter++ does real harm, since noise dilutes the comments that do carry context.
Here’s the test on a pair where the line of code is identical.
// add one to the retry countretries += 1;The reader answered “why is it written this way?” the instant they read the line. The comment restates the code in English; nothing comes from outside the file.
// Stripe retries this webhook up to 3×; cap our own retry so the two// don't compound into 9 attempts.retries += 1;Identical code, opposite verdict. The why lives in Stripe’s retry behavior, outside this file. Without the comment, retries += 1 looks arbitrary, and a future reader “simplifies” the cap away.
The pair is near-identical on purpose. People judge comments by length or by whether a line “looks complicated,” and both tabs pass that test. Only one question separates them: does the answer live in the code, or outside it?
The four kinds that earn their place
Section titled “The four kinds that earn their place”Knowledge from outside the file shows up as four recurring kinds of comment. The Server Action below finalizes an invoice and hits all four, one per step. A real function rarely needs all four at once; this is a teaching specimen, and its density is a signal we return 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. The constraint lives in Postgres, 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 arrival order and reintroduces a double-charge under 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. A shared listInvoiceLines() helper sits right there, skipped because its join is too expensive at this scale. Gone: a reviewer or an agent “tidies” the narrow select into the helper and doubles the 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 awaits and a rare crash ships an unaudited receipt. Hold onto this one: it’s the kind most often stripped, and the one we’ll promote into enforcement shortly.
Each names what breaks if it’s missing, and 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 is what makes each a why, not a what. These four aren’t invented for this lesson; they are the same inline notes a well-run codebase’s conventions allow.
How to write the comment
Section titled “How to write the comment”Knowing a line earns a comment is half the skill; writing it is the other half. The reader has already read the code, so the comment adds only what 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.
- Use the constraint + response shape, 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; “somehow” admits the writer hadn’t decided. Three lines that say less than one would.
// Stripe events can arrive out of order; dedup on event_id, not arrival time.await recordEvent(event.id);The external fact and the action it forces, in one line. The constraint and the response, no paragraph to parse. This is the constraint + response shape every why-comment wants.
A few small conventions compound across a codebase:
What does not earn a comment
Section titled “What does not earn a comment”Each no case comes down to the same thing: some other tool already does the job the comment is reaching for.
- Restating the code.
// loop over invoicesabovefor (const invoice of invoices). The code says it; the comment is a second copy in a worse language. - Section dividers.
// === HELPERS ===. If a file needs internal signposts, split the file. - Author and date stamps.
// Created by Maria 2024-03-15.git blameknows who wrote every line and when, and never goes stale; the comment rots the first time someone edits the line. - Commented-out code. A block disabled “in case we need it later.” You won’t, and
git logrecovers it if you do. Always safe to delete. - Bare TODOs.
// TODO: fix this, with no owner and no ticket, is a wish, and wishes pile up forever. Put the work in the issue tracker; reserve an inline// TODO(SAAS-1234): …for work shipping this sprint. - Fossil comments. A note explaining a workaround for a bug fixed three years ago. The workaround should be gone, and the comment with it; left behind, it tells the next reader to fear a problem that no longer exists.
The bare-TODO rule has one wrinkle you’ll meet in this course’s starter code. Stubs carry markers like // TODO(7.6.3) — implement createInvoice: not bare, but owned by the lesson that builds the stub and due when it lands. Same rule, different form.
The fossil and the dead block show that a comment can lie: the code moves on, the comment doesn’t. Catching that before it merges is a review problem the next lesson picks up.
Spotting noise is the easy half; writing the right comment under pressure is the half that matters. 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.
- Comment 1// increment ii += 1;
- 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);
- 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) {
- Comment 4// const taxRate = legacyRate(region); // old way, leaving for referenceconst taxRate = currentRate(region);
Where the why belongs: comment vs. TSDoc vs. ADR
Section titled “Where the why belongs: comment vs. TSDoc vs. ADR”A why can live in three places. Earlier chapters covered two: rationale on a public surface with TSDoc, and 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.
What separates the three is who reads it, from where, and at what scope. Each panel pairs a line that belongs on that surface with one that doesn’t.
// audit row must commit before the email enqueue A one-line constraint, right next to the code it constrains. // Why we chose Drizzle over Prisma Architectural scope — that's an ADR. // we picked this algorithm because… Rationale isn't the contract — it goes inline. Why we use Drizzle as the ORM A decision that constrains the whole codebase. // round to microseconds here A one-line constraint — it goes inline. Scope decides between comment and ADR: a one-line constraint (“Why await sleep(50)?”) is a comment, a decision that constrains the whole codebase (“Why Drizzle?”) is an ADR. Rationale never goes in TSDoc, since a caller hovering a name wants the contract, not your reasoning about a line.
Comments are part of the code
Section titled “Comments are part of the code”A comment is not decoration beside the code; it is part of the code. When a function is refactored, the // why comments on its lines have to move with those lines. They are not cleanup to sweep away.
Watch how that goes wrong, tracing one load-bearing ordering comment through three releases.
// 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);// 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);const persistInvoiceResult = async (entry: InvoiceResult) => { await enqueueReceiptEmail(entry.id); await writeAuditRow(entry);};The bug returns three releases after the warning was deleted, the worst possible distance: far enough that nobody links the cleanup PR to the failure, close enough that the code still looks fine. The comment was the only thing between “two awaits whose order matters” and “two awaits in any order,” and a tidy-up erased it without asking what it was for.
So here is the reflex, run whenever a refactor would delete a comment:
- Stop, and understand what the comment was preventing.
- 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 here: a comment is the cheapest and weakest form of structural enforcement. It documents that a constraint exists but does nothing to stop you from violating it. It is the fallback for when the type system can’t express the rule.
Promote the comment to enforcement when you can
Section titled “Promote the comment to enforcement when you can”When a comment is preventing a real bug, the move that pays off isn’t writing it more carefully. It’s asking: can I move this constraint from prose into something the compiler or runtime enforces?
Three promotions cover almost every case, and each builds on something earlier in the course:
- An ordering constraint becomes a transactional function.
// audit must commit before the email enqueuebecomes one function that does both inside a transaction, in order. There’s now no way to write the awaits out of sequence, so the comment dissolves. This is the thin-action, pure-libshape you already use. - A “remember to validate” becomes a Zod parse.
// validate the input firstbecomes asafeParseat the boundary, the parse-on-entry discipline from a new angle. The validation is now enforced, not remembered. - A “must be called after auth” becomes a typed argument.
// only call this with an authenticated userbecomes a function that takes an authenticated-session type, or runs behind theauthedActionwrapper. Skipping auth is now a compile error, not an honor-system comment.
Here’s one end to end: the same logic leaning on a comment, then 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: one forgetful call site and unvalidated data reaches the database.
export const createInvoice = async (input: unknown) => { const valid = createInvoiceSchema.parse(input); return db.insert(invoices).values(valid);};The parse runs on entry, and no path into the insert skips it. valid is now a typed, validated value, and the comment is gone. The strongest way to document a why is to make the wrong thing impossible to write.
Not every comment can make that jump. A Postgres-precision constraint or a Stripe out-of-order fact describes an external system, and no type can express that behavior, so those stay comments, well-written ones. Telling which constraints can become code and which must stay prose is the skill.
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 orderparse at the boundary, the ordering becomes one transactional function that can only run the writes in sequence, and the auth precondition becomes a typed-session argument (or the authedAction wrapper) — skipping it turns into a type error. In all three the comment dissolves into a guarantee. The last two describe Postgres’s precision and a provider’s delivery behavior; no type can hold an external system to a promise, so they stay comments — crisp ones.Comment density is a smell
Section titled “Comment density is a smell”If a function needs three or more // why comments to be understood, the function is probably the problem: doing too much, or sitting on the wrong abstraction. One comment flags something non-obvious; a cluster of them flags a design with room to improve. When you reach for the third, treat it as a prompt to split the function or shift the abstraction.
The finalizeInvoice specimen carried all four kinds in one function, useful for showing the shapes side by side, but in real code that density is exactly the smell: it probably wants to be two or three smaller functions, each with one comment or none.
So a comment earns its line when it passes two checks: before you write it, the why must live outside the code; after, the constraint must be one the compiler can’t carry for you.
Where to go deeper
Section titled “Where to go deeper”Two essays take this principle further than a rule can.