Lesson 04 / 16
Indentation and Code Formatting
Taking code format out of the realm of style and measuring it by diff cost: the diff lines produced by the same content in two indentation schemes, the diff line counts the same revision produces in an aligned and a plain form of the tariff table, and separating out the diff lines that carry no content.
Contents
The previous lesson brought every function down to a single level of abstraction, but nothing was yet said about the file itself: where lines end, which declarations sit next to each other, how much indentation there should be. These decisions are usually treated as style, and the argument about them usually gets settled by taste.
Format has a measurable consequence. Every change to a codebase produces a diff, and someone else reads that diff and approves it. The same content change produces a different number of diff lines in different formats, and some of those lines carry no information at all. This lesson ties format to two numbers: the number of diff lines a change produces, and how many of those carry no content.
Indentation Shows the Structure Itself
Indentation is the only signal that carries the depth of nested blocks to the eye. When structure and indentation part ways, whoever is reading has to find where a block ends by counting curly braces by hand. The two files below carry the same sequence of words; only their indentation differs.
mkdir -p tidy messy aligned plain
// tidy/fee.mjs — indentation matches structural depth const MINIMUM_FEE = 52; const WEIGHT_TIERS = [ { upperLimitKg: 5, ratePerKg: 26 }, { upperLimitKg: 30, ratePerKg: 17 }, ]; export function tierFee(weightKg, zoneCoefficient) { const tier = WEIGHT_TIERS.find((t) => weightKg <= t.upperLimitKg); if (tier === undefined) { throw new RangeError("weight outside tier range"); } return Math.max(tier.ratePerKg * weightKg * zoneCoefficient, MINIMUM_FEE); }
// messy/fee.mjs — same content, indentation does not match structure const MINIMUM_FEE = 52; const WEIGHT_TIERS = [ { upperLimitKg: 5, ratePerKg: 26 }, { upperLimitKg: 30, ratePerKg: 17 }, ]; export function tierFee(weightKg, zoneCoefficient) { const tier = WEIGHT_TIERS.find((t) => weightKg <= t.upperLimitKg); if (tier === undefined) { throw new RangeError("weight outside tier range"); } return Math.max(tier.ratePerKg * weightKg * zoneCoefficient, MINIMUM_FEE); }
In the second file, the throw line is indented less than the condition that holds it.
A reader looking at that line cannot tell from the indentation alone whether it sits
inside the condition or outside it. The same ambiguity is in the first entry of the
WEIGHT_TIERS array: the two entries sit at the same structural depth but in different
columns.
The difference between the two files is measurable. The first line of each file is the marker carrying the filename, so it does not enter the comparison.
diff <(tail -n +2 tidy/fee.mjs) <(tail -n +2 messy/fee.mjs)
3c3
< { upperLimitKg: 5, ratePerKg: 26 },
---
> { upperLimitKg: 5, ratePerKg: 26 },
8,9c8,9
< const tier = WEIGHT_TIERS.find((t) => weightKg <= t.upperLimitKg);
< if (tier === undefined) {
---
> const tier = WEIGHT_TIERS.find((t) => weightKg <= t.upperLimitKg);
> if (tier === undefined) {
11c11
< }
---
> }
Eight diff lines, and none of them describe a behavior change. This is the first cost of format disagreement: whoever fixes the indentation produces an eight-line diff without changing any content, and someone else has to read that diff.
The Tariff Table’s Two Formats
The second measurement answers a more concrete question: how many diff lines does the same content change produce in two different formats? The tariff module was written in two formats. In the first, the equals signs are aligned and the tier table is packed two entries per line; in the second, declarations carry a single space and every tier sits on its own line.
// aligned/tariff.mjs — aligned declarations, packed tier table export const VOLUMETRIC_DIVISOR = 3000; export const MINIMUM_FEE = 52; export const CONTRACTED_CUSTOMER_DISCOUNT = 0.15; export const CURRENCY = "TL"; export const WEIGHT_TIERS = [ { upperLimitKg: 1, ratePerKg: 38 }, { upperLimitKg: 5, ratePerKg: 26 }, { upperLimitKg: 10, ratePerKg: 21 }, { upperLimitKg: 30, ratePerKg: 17 }, ];
// plain/tariff.mjs — single-space declarations, one tier per line export const VOLUMETRIC_DIVISOR = 3000; export const MINIMUM_FEE = 52; export const CONTRACTED_CUSTOMER_DISCOUNT = 0.15; export const CURRENCY = "TL"; export const WEIGHT_TIERS = [ { upperLimitKg: 1, ratePerKg: 38 }, { upperLimitKg: 5, ratePerKg: 26 }, { upperLimitKg: 10, ratePerKg: 21 }, { upperLimitKg: 30, ratePerKg: 17 }, ];
The carrier revisited the tariff and three things changed: the rate for the five
kilogram tier dropped from 26 to 25, a new tier opened up to fifty kilograms, and the
CONTRACTED_CUSTOMER_DISCOUNT constant was renamed to CONTRACT_DISCOUNT. The same
three changes are applied to both files.
// aligned/tariff-new.mjs — same revision, alignment preserved export const VOLUMETRIC_DIVISOR = 3000; export const MINIMUM_FEE = 52; export const CONTRACT_DISCOUNT = 0.15; export const CURRENCY = "TL"; export const WEIGHT_TIERS = [ { upperLimitKg: 1, ratePerKg: 38 }, { upperLimitKg: 5, ratePerKg: 25 }, { upperLimitKg: 10, ratePerKg: 21 }, { upperLimitKg: 30, ratePerKg: 17 }, { upperLimitKg: 50, ratePerKg: 14 }, ];
// plain/tariff-new.mjs — tariff revision applied export const VOLUMETRIC_DIVISOR = 3000; export const MINIMUM_FEE = 52; export const CONTRACT_DISCOUNT = 0.15; export const CURRENCY = "TL"; export const WEIGHT_TIERS = [ { upperLimitKg: 1, ratePerKg: 38 }, { upperLimitKg: 5, ratePerKg: 25 }, { upperLimitKg: 10, ratePerKg: 21 }, { upperLimitKg: 30, ratePerKg: 17 }, { upperLimitKg: 50, ratePerKg: 14 }, ];
In the aligned version, the shortened name moved the alignment column, so all four declarations were rewritten. In the plain version, only the declaration whose name actually changed was rewritten.
The Diff Measurer
The tool below diffs each file pair and splits the diff lines in two. If a removed line, once its whitespace is collapsed, also shows up among the added lines, that pair is a format-only change; it carries no content. What remains is a content change.
// diff-measure.mjs — how many of the diff lines between two files carry content import { execSync } from "node:child_process"; // The first line is the filename marker; it does not enter the comparison. const diff = (before, after) => execSync(`diff <(tail -n +2 ${before}) <(tail -n +2 ${after}) || true`, { shell: "/bin/bash" }) .toString().split("\n"); const content = (s) => s.slice(1).replace(/\s+/g, " ").trim(); const PAIRS = [ ["tidy/fee.mjs", "messy/fee.mjs"], ["aligned/tariff.mjs", "aligned/tariff-new.mjs"], ["plain/tariff.mjs", "plain/tariff-new.mjs"], ]; for (const [before, after] of PAIRS) { const lines = diff(before, after); const removed = lines.filter((s) => s.startsWith("< ")).map(content); const added = lines.filter((s) => s.startsWith("> ")).map(content); const matched = removed.filter((s) => added.includes(s)).length; console.log(`${before.padEnd(18)} -> ${after.padEnd(22)} changed=${removed.length + added.length}` + ` format only=${matched * 2} content=${removed.length + added.length - matched * 2}`); }
tidy/fee.mjs -> messy/fee.mjs changed=8 format only=8 content=0 aligned/tariff.mjs -> aligned/tariff-new.mjs changed=11 format only=6 content=5 plain/tariff.mjs -> plain/tariff-new.mjs changed=5 format only=0 content=5
The three rows say the following. The indentation fix produces eight diff lines and all eight are format; the content diff is zero. The tariff revision’s content diff is the same in both formats: five lines. In the aligned format, six more lines are added next to those five.
Whoever Reads the Diff
What the numbers mean is the job of whoever reads the diff. In the eleven-line diff, five lines carry a decision and six do not; the reader does not know beforehand which is which and has to compare all of them. In the five-line diff, there is no such sorting to do.
The packed table has a second effect too. In the aligned version’s diff output, the changed table row carries two tiers at once: one changed, one did not. The reader has to search character by character for where in the row the change sits. In the plain version, the changed row carries a single tier, so the comparison ends with the row itself.
This is where the format decision’s measure comes from: a line should carry one piece of information, and one piece of information’s format should not depend on its neighbor. Alignment breaks the second condition; when one name’s length sets the whitespace count on neighboring lines, a declaration whose name changes rewrites its neighbors too. Packing more than one item per line breaks the first condition.
The Format Decision Gets Made Once
The measurement does not say exactly which format to choose; whether indentation should be two or four spaces does not show up in these numbers. What it says is that the decision should be made once, at the project level, and not reopened file by file. When two indentation habits live in the same project side by side, every file turns into whoever last worked on it, and every such turn produces a diff line that carries no content.
For this reason the format decision is a matter for a configuration file, not a discussion. A tool from the formatter class applies the rules to the source mechanically; the decision gets made once, and applying it is not left to the individual. The format the tool picks may not line up with anyone’s preference exactly; the measured cost is larger than the cost of a preference mismatch.
Summary
- The measure of a format decision is the number of diff lines it produces: every change produces a diff, and someone else reads that diff.
- A file with only its indentation broken produced eight diff lines and all eight were content-free; a format mismatch has a cost even when nothing changes.
- The same tariff revision produced five diff lines in the plain format and eleven in the aligned format; the content diff was five lines in both, the remaining six came only from alignment.
- A line should carry one piece of information, and a piece of information’s format should not depend on the neighboring lines; alignment breaks the second condition, packing several items per line breaks the first.
- The format decision gets made once at the project level and applied mechanically; a decision remade file by file produces a content-free diff every time.
Next Step
Format determines how code looks to the eye, not the structure itself. A properly indented function can still be hard to read: nested conditions, multiple exit points, and compound logical expressions do not get fixed by indentation. This difficulty has a number too. The next lesson writes a measurer that counts a function’s decision points, compares cyclomatic complexity and the number of linearly independent paths across two versions of the fee calculation, and then applies the transformations that lower the number.
To keep your progress and take notes, Log in
My notes
Log in to take notes.