open atlas
↑ Back to track
Code patterns & craft CP · 12 · 02

The rule of three

Don't abstract on the first or second occurrence — wait for the third, when the real axis of variation is finally visible. Abstracting early guesses the axis wrong and ossifies the wrong shape. The exception: obvious knowledge duplication, which you de-dupe immediately.

CP Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

You write a function. A week later you write a second one that looks suspiciously similar. The DRY reflex fires: extract it, share it, never repeat yourself. So you hoist a shared() helper with a mode flag to paper over the one place the two differ — and feel virtuous.

Then the third caller arrives, and it differs along a completely different axis than the one your flag captured. Now your “shared” helper grows a second flag, then a branch, then a callback, until it is a knot that every caller fights and none of them fits. Two examples did not show you the pattern. They showed you a pattern — and you guessed wrong.

Goal

After this lesson you can state the rule of three and explain why it is a deliberate bias against speculative abstraction, not laziness; tell the difference between incidental similarity (two things that happen to look alike) and a real shared concept; recognise that a wrong abstraction is more expensive than the duplication it replaced; and name the one case where the rule does not apply — obvious, knowable knowledge duplication you must de-duplicate on sight.

1

Two occurrences don’t reveal the axis of variation — three do. An abstraction is a claim: “these cases are the same except along this one axis.” With one occurrence you have no axis at all. With two, you can see a difference, but you can’t tell whether it’s the difference — the dimension future cases will actually vary along — or just an accident of these particular two.

// Occurrence #1
function priceForBook(b: Book) {
  return b.basePrice * 1.2; // VAT
}

// Occurrence #2 — looks like the same shape with a different rate
function priceForEbook(e: Ebook) {
  return e.basePrice * 1.07; // reduced VAT
}

Tempting to extract priceWith(rate). But is rate the real axis? Or will the next case vary by region, by customer type, by promotional override? Two points define a line, and a line through two points always fits perfectly — which is exactly why it tells you nothing about where the third point lands.

2

Premature abstraction guesses the axis wrong, then ossifies the wrong shape. The danger isn’t the wasted effort of extracting early — it’s that the early abstraction commits you. Once three callers depend on priceWith(rate), that signature is load-bearing. When the real variation turns out to be regional tax tables, you can’t just change the abstraction; you have to fight it: add a second parameter, thread a flag, special-case one caller, and the helper accretes complexity to cover a shape it was never designed for.

// What "extract on the second" decays into by the fifth caller:
function price(base: number, rate: number, region?: Region,
               isExempt?: boolean, override?: number) {
  if (override != null) return override;
  if (isExempt) return base;
  const r = region ? rateFor(region) : rate; // the original `rate` is now vestigial
  return base * r;
}

Every flag is scar tissue from a wrong guess. The abstraction now obscures more than it shares.

3

Duplication is cheaper than the wrong abstraction — until the pattern is clear. This is the senior inversion of the junior instinct. Junior reasoning: “duplication is always bad, eliminate it now.” Senior reasoning: duplication has a known, bounded cost — N copies, each editable in place, each independently correct. The wrong abstraction has an unknown, growing cost — every caller is now coupled through a shape that doesn’t fit, and untangling it is harder than if they’d stayed apart, because you must first re-inline the abstraction before you can split it correctly.

So the rule of three is a bet on cost asymmetry: paying the small, visible cost of duplication for one more occurrence buys you the information to abstract correctly — and a correct abstraction is the only one worth its indirection. Waiting is the cheaper move precisely because you don’t yet know enough to be right.

4

The exception: obvious knowledge duplication is not “waiting for a third example.” The rule of three governs structural similarity — code that happens to look alike but whose shared concept is still unproven. It does not license copy-pasting a fact you already know is single-sourced. A tax rate, a regulatory threshold, a magic API path, a business rule with one true value — these are knowledge duplication, and the third, fourth, and twelfth copy don’t reveal anything new. You already know it’s one concept.

// NOT a rule-of-three candidate — this is one fact, copied:
const fee = subtotal * 0.029 + 30;   // billing.ts
const fee = subtotal * 0.029 + 30;   // refund.ts
const fee = subtotal * 0.029 + 30;   // report.ts   ← 0.029 in 12 files is a bug waiting to drift

Hiding behind “rule of three” to avoid naming STRIPE_FEE is a misuse. The rule defers abstraction when the pattern is uncertain; it never defers de-duplicating a value you can already name. The test: can you give the shared thing a true, stable name right now? If yes, it’s knowledge — extract it. If the only honest name is doTheSimilarThing, the axis isn’t clear yet — wait.

Worked example

Let the third caller name the axis. Two report exporters, left deliberately duplicated:

// csvExport.ts
function exportUsers(users: User[]): string {
  const header = "id,name,email";
  const rows = users.map(u => `${u.id},${u.name},${u.email}`);
  return [header, ...rows].join("\n");
}

// csvExportOrders.ts
function exportOrders(orders: Order[]): string {
  const header = "id,total,status";
  const rows = orders.map(o => `${o.id},${o.total},${o.status}`);
  return [header, ...rows].join("\n");
}

At two occurrences, what varies looks like “the columns.” If you extracted now, you’d build toCsv(rows, columns) keyed on columns. Then the third caller arrives:

// xmlExportInvoices.ts  ← the real axis was the FORMAT, not the columns
function exportInvoices(invoices: Invoice[]): string {
  return `<invoices>${invoices.map(i =>
    `<invoice id="${i.id}" total="${i.total}"/>`).join("")}</invoices>`;
}

Now the true axis is visible: serialisation format, not column set. A toCsv(columns) helper extracted on the second occurrence would have fought this — invoices don’t even produce rows-and-columns. The correct abstraction the third occurrence reveals is a small Serializer seam:

interface Serializer<T> { (records: T[]): string; }

const csv = <T>(cols: (keyof T)[]): Serializer<T> => recs =>
  [cols.join(","), ...recs.map(r => cols.map(c => r[c]).join(","))].join("\n");

const xml = <T>(tag: string): Serializer<T> => recs =>
  `<${tag}s>${recs.map(/* ... */).join("")}</${tag}s>`;

The duplication cost us two near-identical functions for one extra occurrence. In exchange we abstracted along the axis that’s actually real, instead of ossifying “columns” — a shape the third case would have broken.

Why this works

Why three and not two, or four? It’s a heuristic, not a theorem — the number encodes a cost trade-off. One occurrence: nothing to abstract. Two: you can see a difference but can’t distinguish the real axis from a coincidence, and a two-point fit is always perfect, so it’s maximally misleading. Three is the smallest sample where a direction emerges — the dimension along which cases genuinely vary starts to separate from incidental differences. It’s the point where the cost of continued duplication starts to clearly exceed the (now much lower) risk of guessing the abstraction wrong. Treat three as “enough signal,” not a magic constant; a strong, obvious pattern can justify two, and a murky one might warrant waiting for four.

Common mistake

The two failure modes are symmetric, and seniors miss in both directions. Over-eager: treating the rule as “DRY, immediately” — extracting on the second occurrence and shipping a flag-riddled helper that every later caller fights. Over-patient: weaponising the rule to dodge work you know is a single concept — leaving 0.029 (or a parsing routine, or an auth check) copied across the codebase and waving “rule of three” at the reviewer. The first abstracts uncertainty; the second refuses to abstract certainty. The discriminator is the name: if you can give the shared thing a true, stable name today, it’s knowledge — de-duplicate now. If the most honest name you can give it is “the similar-looking thing,” the axis isn’t clear — wait for the third example to tell you what it actually is.

Check yourself
Quiz

You see the literal payment-fee formula <code>amount * 0.029 + 30</code> copy-pasted into a second file. A teammate says 'rule of three — leave it until we have a third copy.' What's the correct senior response?

Recap

The rule of three is a deliberate bias against speculative abstraction: don’t extract on the first or second occurrence, because two cases can’t reveal the true axis of variation — they fit any line you draw through them. Abstracting early guesses that axis wrong and then ossifies the wrong shape, accreting flags and branches as later callers fight a seam that was never designed for them. The senior inversion is that duplication is cheaper than the wrong abstraction until the pattern is clear: duplication’s cost is bounded and local, while a wrong abstraction’s cost is unknown and coupling. The one hard exception is knowledge duplication — a single known fact like a fee rate or a business rule copied across files. That isn’t waiting for a third example; it’s a drift bug. The discriminator is whether you can give the shared thing a true, stable name today: if yes, de-duplicate now; if the only honest name is “the similar thing,” wait for the third occurrence to tell you what it really is.

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.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.