Dead code & speculative generality
Dead code and speculative generality are dispensable smells: unused params, unreachable branches, never-called functions, and "just in case" abstractions that cost reading and testing for zero benefit. Cure: delete fearlessly — git remembers, YAGNI re-adds when truly needed.
You open a 400-line module to make one change. Forty of those lines are a commented-out implementation from two years ago. One function takes a legacyMode flag that is always false. There is a BaseExporter abstract class with exactly one subclass, plus a strategy parameter that has only ever been called with "default". None of it runs. None of it does anything. But all of it is still there, in your way, demanding to be read and reasoned about before you can touch the line you came for.
This is the cheapest category of smell to fix and the one engineers fix least, because deleting code feels risky and adding “flexibility” feels responsible. Both instincts are backwards. The dispensables — dead code and speculative generality — are pure cost: more to read, more to maintain, more to test, in exchange for nothing you use today.
After this lesson you can recognise the forms of dead code (unused params, unreachable branches, commented-out blocks, never-called functions) and speculative generality (abstractions, hooks, and config flags built for a future that never arrived); explain why unused flexibility is a cost and not an asset; delete it fearlessly under version control; and name the one failure mode — pulling out a genuinely-needed extension point that merely happens to have a single caller today.
Dead code is code that cannot affect the program’s output — and it still costs you. It comes in a few recurring shapes:
// 1. unused parameter — every caller passes something the body ignores
function priceOf(item: Item, _currency: string): number {
return item.cents / 100; // currency is never read
}
// 2. unreachable branch — the guard above already returns
function classify(n: number): string {
if (n >= 0) return "non-negative";
if (n < 0) return "negative";
return "impossible"; // dead: every number is one or the other
}
// 3. commented-out block — a fossil nobody dares delete
// function oldExport() { ...40 lines... }
// 4. never-called function — exported, imported nowhere
export function legacyMigrate() { /* ... */ }The damage is not that it runs wrong — it does not run at all. The damage is that a reader cannot know it’s dead without proving it, so every one of these forces a small investigation: “is _currency used somewhere I can’t see? does anything still call legacyMigrate?” That tax is paid on every read, by every future maintainer, forever.
Speculative generality is flexibility built for a requirement that never came. Dead code is at least honest — it once did something. Speculative generality never did. It is the abstraction, hook, or parameter added “in case we need it”:
// one subclass, ever — the abstraction buys nothing
abstract class Exporter {
abstract format(rows: Row[]): string;
}
class CsvExporter extends Exporter { format(rows: Row[]) { /* ... */ return ""; } }
// no other Exporter exists, none is planned
// a strategy parameter only ever called one way
function render(view: View, strategy: "default" | "fast" = "default") {
// 'fast' branch was never written; callers never pass it
}
// a config flag wired through three layers, always the same value
const FEATURE_NEW_PIPELINE = false; // shipped false, never flippedEach of these adds an indirection, a branch, or a type union you must hold in your head to read the code — and none of it does anything. You pay the full comprehension and maintenance cost of flexibility while getting zero flexibility back, because the variation it was built for does not exist.
Unused flexibility is not an asset on the balance sheet — it is a liability. This is the senior reframe. “We might need it later” treats an abstraction as a saved investment. It is the opposite: every extension point is more surface to read, more code paths to test, more constraints on the next refactor (you can’t simplify Exporter away without checking the abstract contract), and more onboarding load. An empty strategy branch is a lie the type system tells — it claims a behaviour that does not exist.
The asset is delivered behaviour you use. Flexibility you don’t use is undelivered cost. YAGNI — You Aren’t Gonna Need It — is the economic statement of this: building for a speculative future loses on average, because most speculated futures never arrive, and the ones that do rarely match the shape you guessed. You pay the carrying cost now, with interest, for an option you usually never exercise.
The cure is delete — and version control is what makes it fearless. The reason engineers hoard dead code is the fear “what if we need it again?” Git answers that completely: deleted code is not gone, it is one git log / git show away, with its full history and the commit message explaining why it existed. Commented-out blocks are worse than deletion — they rot (the surrounding API drifts, so the fossil no longer even compiles), they pollute search results, and they carry no history. Delete them; the real version is in the log.
// before — the fossil and the flag, in the reader's way
function priceOf(item: Item, _currency: string): number { return item.cents / 100; }
// function oldPriceOf(item) { ...40 dead lines... }
// after — what the code actually does, and nothing else
function priceOf(item: Item): number {
return item.cents / 100;
}So delete fearlessly: unused params, unreachable branches, never-called exports, single-implementation abstractions, dead flags. The bet YAGNI makes is that re-adding the abstraction when you actually have the second case is cheaper than carrying it speculatively until then — and that bet is almost always right, because the second case tells you the real shape the abstraction needs.
The failure mode: deleting a genuinely-needed extension point because it has only one caller today. “One subclass” and “one caller” are the symptoms of speculative generality — but they are also what a correct, deliberate seam looks like the moment before its second use lands. The single-caller PaymentProvider interface with only StripeProvider behind it may be over-engineering — or it may be the boundary your team agreed on last sprint because the PayPal integration starts Monday and the seam is load-bearing for a contract test or a plugin API.
Before you delete, confirm it is speculative, not early:
- Is there a concrete, committed plan or an existing consumer (a test double, a public API boundary, a second implementation in flight)? Then it is early infrastructure — keep it.
- Is it “we might want to swap this someday” with no second case in sight, no test exercising the seam, and no caller using the abstraction? Then it is speculative — delete it and re-add when the real case appears.
The senior move is not “never abstract” and not “delete everything with one caller.” It is distinguishing flexibility you are paying for and not using from a seam you are about to use, and being honest about which one you’re looking at.
Before — a module carrying flexibility it never uses. A reporting exporter, built “to support multiple formats,” has shipped with exactly one format for a year:
abstract class ReportExporter {
abstract export(rows: Row[], options?: ExportOptions): string;
}
interface ExportOptions {
delimiter?: string; // only ',' ever passed
encoding?: string; // only 'utf-8' ever passed
legacyHeader?: boolean; // always false
}
class CsvExporter extends ReportExporter {
export(rows: Row[], options: ExportOptions = {}): string {
const delim = options.delimiter ?? ",";
if (options.legacyHeader) { /* never executed */ }
return rows.map((r) => r.cells.join(delim)).join("\n");
}
}
// the only caller, in one place:
new CsvExporter().export(rows);Read what’s actually used: a function that joins cells with commas. Everything else — the abstract base, the three options, the legacyHeader branch — is speculative. There is no second exporter, no second delimiter, no caller that passes any option. The flexibility is pure carrying cost: every reader must understand ReportExporter, ExportOptions, and a dead branch to learn that reports are comma-joined.
After — delete the unused flexibility; keep exactly the behaviour you ship.
function exportCsv(rows: Row[]): string {
return rows.map((r) => r.cells.join(",")).join("\n");
}
exportCsv(rows); // the one caller, now obviousThe abstract class, the options object, and the dead branch are gone. If a second format genuinely arrives, you re-introduce a seam then — and it will fit the real second case (you’ll know whether formats differ by delimiter, by structure, or by streaming) instead of the one you guessed. Git still holds the old ReportExporter if you ever want to see what the speculation looked like. Before you cut, you confirmed there is no PDF exporter in flight and no public-API contract depending on ReportExporter; that check is the difference between deleting speculation and amputating a seam.
▸Why this works
Why is deleting safe even when it feels risky? Because version control turns deletion into a reversible operation with a paper trail. A deleted function lives forever in the commit that removed it; git log -S'legacyMigrate' finds the exact commit, and the diff plus message tell you why it went. Carrying the code “just in case” buys you nothing git doesn’t already give you — and costs you the daily reading tax. The asymmetry is the whole argument: keeping speculative code has a recurring cost paid by every reader on every read; re-adding it later (if ever) is a one-time cost paid once, by one person, when the need is real and the requirements are finally known. YAGNI is just the rational response to that asymmetry.
▸Common mistake
The expensive mistake is using “only one caller” or “only one subclass” as a sufficient reason to delete. It’s a symptom, not a verdict. A PaymentProvider interface with one implementation can be textbook speculative generality — or it can be the deliberate seam your team is building this sprint because a second provider lands next week, with a contract test already pinning the interface. Deleting that isn’t simplification; it’s tearing out load-bearing infrastructure and forcing a re-add under deadline. The fix is a thirty-second check before the delete: is there a committed second case, an existing test against the seam, or a public boundary that depends on it? If yes, it’s early, not speculative — leave it. Delete confidently, but confirm “speculative, not just early” first.
You find a PaymentProvider interface with a single implementation, StripeProvider. No other provider exists in the code. What's the senior move?
Dead code (unused params, unreachable branches, commented-out fossils, never-called functions) and speculative generality (abstractions, hooks, flags, and “just in case” parameters for a future that never arrived) are the dispensable smells: pure cost — more to read, maintain, and test — for zero current benefit. The senior reframe is that unused flexibility is a liability, not an asset; the asset is delivered behaviour you use. The cure is to delete fearlessly, because version control remembers (deletion is reversible, with full history) and YAGNI says the second case will tell you the real shape when it actually arrives — and re-adding then is cheaper than carrying speculation now. The one failure mode to guard against: deleting a genuinely-needed extension point that merely happens to have a single caller today. Before you cut, confirm it’s speculative, not just early — no committed second case, no test pinning the seam, no public boundary depending on it. Then delete with confidence.
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.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.