What has to be true before a third copy earns a library?
Write the abstraction at the third repetition, and only when the thing repeated is prose you would otherwise have to keep in sync. A third copy of code that merely resembles the other two is a coincidence, and extracting it buys you a parameter list that will grow one flag per caller.
I have written both kinds. The extractions that paid for themselves all had one fact stated in several places that had to agree, and no compiler or test that could make them agree. The extractions that became a maintenance tax were all shapes that matched on the afternoon I noticed them: two functions taking the same three arguments, two components rendering similar markup for reasons that had nothing to do with each other.
Write the library when the third repetition is prose — a vocabulary, a mapping, a sentence a customer reads — that must stay identical in several places and that no build step checks.
That is a narrower rule than "three strikes and you refactor", and the narrowness is the whole value. The familiar version tells you when you are tired of typing. This one tells you when a machine could hold the invariant for you, which is the only thing a library does better than discipline.
Where does the rule of three give the wrong answer?
Lookalike code diverges by design, and the divergence is the reason to leave it alone. Two retry wrappers are the cleanest example I know. One backs off because a vendor rate-limits at 20 requests per second; the other backs off because a job queue is contended and the worker is not the bottleneck. Same shape, same arguments, same three lines of exponential delay. Abstract them and you get withRetry(fn, { policy: "vendor" | "queue" }), where the policy flag is the abstraction admitting in public that the call sites never had a shared reason to change.
The honest measure is not how many times you have written something. It is how many files one conceptual change has to reach, and whether anything mechanical would notice if it reached only some of them.
| What repeated | Does one change have to reach every copy? | Verdict |
|---|---|---|
| A status vocabulary used by the API parser, the email template and the dashboard | Yes — a rename or an addition that misses one copy produces wrong output, not an error | Extract, generate the derived copies |
| Two retry wrappers with different backoff triggers | No — they change on different schedules for different reasons | Leave duplicated |
A CHECK constraint and a TypeScript union holding the same allowed values | Yes — a value in one place either breaks inserts or becomes unreachable code | Extract, generate one from the other |
| Two components with similar JSX over unrelated data | No | Extract only the primitive they genuinely share |
| A cache key format in the worker and in the invalidator | Yes — drift means silently stale reads, with no exception anywhere | Extract |
| A validation message quoted in three test files | Yes, but a failing test already checks it | Leave; the test is the invariant |
The last row is the one people skip. If an integration test fails within a minute of the copies diverging, you already have the mechanical check, and a library buys indirection without buying a new guarantee. The rows worth a library are the ones that are silent when they break.
What does prose that must stay in sync look like in code?
A job status vocabulary. It is prose because "failed" is a sentence a customer reads in an email and "cancelled" is a state your support team discusses on a call, while the database stores a token. Here is the version I inherited, reduced to its essentials:
// The same vocabulary, copied into each of the three places that consume it.
// packages/api/src/schema.ts
import { z } from "zod";
export const jobStatus = z.enum(["queued", "running", "succeeded", "failed"]);
// emails/job-finished.ts
const statusLabel: Record<string, string> = {
queued: "Queued", running: "Running", succeeded: "Done", failed: "Failed",
};
// db/migrations/012_job_status.sql, typed out from the same list by hand
// CREATE TYPE job_status AS ENUM ('queued','running','succeeded','failed','cancelled');
Three copies, five values in the database and four everywhere else. A cancel feature shipped, the migration added cancelled, and the two downstream copies kept their four. The parser rejected rows its own database had just written, so the job page returned a 500 for every cancelled job. The email template was worse: it interpolated statusLabel[status] inside a sentence, so a cancelled job produced "Your job is undefined" in a customer's inbox. TypeScript said nothing about any of it, because the label map was typed Record<string, string> — a type wide enough to accept every possible mistake, which is the same as having no type.
The fix is one declaration, and the work is in deciding what the declaration owns. It owns the token, the label, and the two booleans that every consumer was re-deriving from the token:
// statuses/job.ts — the only place a job status is named or described.
export const JOB_STATUSES = {
queued: { label: "Queued", terminal: false, customerVisible: true },
running: { label: "Running", terminal: false, customerVisible: true },
succeeded: { label: "Done", terminal: true, customerVisible: true },
failed: { label: "Failed", terminal: true, customerVisible: true },
cancelled: { label: "Cancelled", terminal: true, customerVisible: false },
} as const;
export type JobStatus = keyof typeof JOB_STATUSES;
// Zod wants a non-empty tuple, so the assertion belongs to the contract:
// adding a key above widens the union and the parser in one edit.
export const jobStatusValues = Object.keys(JOB_STATUSES) as [JobStatus, ...JobStatus[]];
export function labelFor(status: JobStatus): string {
return JOB_STATUSES[status].label;
}
The compiler now carries the vocabulary: labelFor cannot be called with a string, the parser is built from the same keys, and deleting a status breaks every consumer at build time rather than at 2am. The database is the one consumer that cannot read a TypeScript module, so it gets a generated file instead of a hand-written one.
-- Generated from statuses/job.ts by scripts/generate-enums.ts. Do not edit.
CREATE TYPE job_status AS ENUM ('queued','running','succeeded','failed','cancelled');
CREATE TABLE jobs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
status job_status NOT NULL DEFAULT 'queued',
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX jobs_status_updated_idx ON jobs (status, updated_at DESC);
-- Read the live vocabulary back out of Postgres, in declaration order, so the
-- drift check compares labels and not just the set of them.
SELECT e.enumlabel
FROM pg_enum e
JOIN pg_type t ON t.oid = e.enumtypid
WHERE t.typname = 'job_status'
ORDER BY e.enumsortorder;
The same test applied to visual values gives the argument I made about design tokens: a token file that nothing in the build consumes is documentation, not a design system. The mechanism is identical in both cases — one declared source, generated outputs, a build that fails when they disagree — and so is the failure it prevents: a declaration that is correct in the file you edited and wrong in the three you did not.
How do you keep the extraction honest once it exists?
A generator without a check is a second copy with extra steps. The moment the generated file can be edited, someone will edit it, and you are back to two sources of truth with a comment at the top asking people not to. Two things make the arrangement hold: the generated artefacts are marked and treated as read-only, and CI regenerates them and fails on any diff.
# 1. Regenerate, then fail if the working tree changed: a status added in
# TypeScript cannot ship without a regenerated migration.
$ pnpm tsx scripts/generate-enums.ts
$ git diff --quiet -- db/migrations || { echo "generated SQL is stale"; exit 1; }
# 2. Against a live database, compare what Postgres actually has with what the
# TypeScript source says, order included, so labels and ordering both match.
$ psql "$DATABASE_URL" -Atc "SELECT string_agg(e.enumlabel, ',' ORDER BY e.enumsortorder)
FROM pg_enum e JOIN pg_type t ON t.oid = e.enumtypid WHERE t.typname = 'job_status'"
queued,running,succeeded,failed,cancelled
My own observation, from the two repositories where I have made this change: the pull request that introduces the single source touches one file per consumer and is usually under 40 lines, while the drift it removes had already produced one customer-visible incident in each. That ratio is the reason I stopped treating these extractions as tidying and started treating them as corrective work.
The rule I apply to the extracted module is one-way: it may not import from any consumer, and no consumer may re-derive a value the module already exposes. When a consumer needs a new fact — "cancelled jobs are excluded from the SLA denominator" — the fact goes in the module, even if only one caller reads it today. The moment a consumer derives its own version of the vocabulary, the library is back to being documentation with better branding.
When is building the library the wrong call?
Most of the time, in fact. The rule is a gate, not an instruction, and the following are the cases where it closes.
- Two consumers that differ by one thing. The abstraction becomes a boolean parameter, and the boolean is a defect you will carry until you delete the library. Wait for a third real difference, or accept the copy.
- A package with one caller. A package boundary is permanent — build config, version bumps, a changelog, a release step — and the second caller often never arrives. A module inside the existing package costs nothing and can be split later.
- The copies currently disagree. Two implementations that contradict each other may be encoding a business rule nobody wrote down. Unifying them settles a policy question silently, in code, and whichever version you keep becomes the rule by accident. Write the rule down first; abstract second.
- The repeated thing is a third-party surface. Extracting over someone else's API shape pins your code to their churn. Wrap the two calls you actually use, not their whole client.
- The copies sit across release boundaries. If one lives in a browser bundle and the other in a worker deployed on a different schedule, a shared module forces lockstep releases. Count that as a real cost against the drift you are preventing.
| Symptom | What the abstraction costs | Better first move |
|---|---|---|
| Two call sites differing by a flag | A configuration surface with one legal combination | Do nothing until a third caller appears |
| Copies in separately deployed artefacts | Lockstep releases, or a generated file plus a check | Generate one from the other, or keep the copy |
| The copies contradict each other today | Hides the question of which behaviour is correct | Decide the rule in writing, then unify |
| A fast integration test already catches drift | Indirection with no new invariant | Keep the duplicate and keep the test |
| Customer-visible strings duplicated across three files | A single rename reaching some of them, silently | Extract the vocabulary now |
There is also a cost that only shows up months later: an extracted module becomes a place where unrelated concerns land, because adding one field to a shared thing feels cheaper than adding a file. I have watched a nine-line status map grow a permissions matrix and a colour ramp before anyone proposed splitting it. The defence is size discipline in the module and a reviewer who asks why each new field belongs to the same fact.
What is the smallest version of this decision?
Take the third copy you are looking at and ask two questions in order: what would break, and would anything tell you. If the answer to the second question is a test, write the test and leave the code alone. If nothing would tell you, extract the declaration, generate every copy a build step can produce, and add the CI job that fails on a diff — that is the whole library, and it does not need a version number or a README.
The cost of a wrong abstraction is not paid once at extraction; it is paid on every subsequent change, by every engineer who has to work around a flag that only one caller wanted. The cost of duplication is paid only when someone edits one copy and misses another, which is exactly the case the third repetition makes visible. So the trigger is real, and the condition attached to it is the part worth remembering: prose, not resemblance.
Keep reading
- GEO for Engineers: Making Your Site Legible to Language Models2026-03-216 minEngineering
- Server Actions vs API Routes: The Caller Decides, Not the Operation2026-02-198 minEngineering
- Your Dashboard Query Is Not Missing a Postgres Index: It Is Fetching 58,412 Rows to Render Twelve2026-02-169 minEngineering