When Inheritance Fits
Real substitutability, a closed and stable set of subtypes, and shared behaviour that is genuinely the same behaviour. Miss any one and you have coupled two types to save typing.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
Under what conditions is extends the right answer rather than the convenient one?
A parser needs twenty node types that all carry a source span, all accept a visitor, and all render back to source. A separate team wants PremiumCustomer extends Customer to reuse the address-formatting method.
These two classes share a method, and the second is a kind of the first. Extend it — one line, no duplication, and the relationship reads naturally in English.
"Is a kind of" in English is a classification claim. extends is a substitutability claim plus an implementation-coupling claim, and the English sentence guarantees neither.
- "Is a kind of" in English is a classification claim.
extendsis a substitutability claim plus an implementation-coupling claim, and the English sentence guarantees neither. - The reuse is real for exactly one release. Then premium customers need a different address format, so the method is overridden; then the base changes and the override silently stops matching; then a third subtype appears whose needs match neither.
- The base class acquires the union of its descendants' reasons to change, so a billing change now recompiles and re-tests the parser-shaped part of it too (Divergent Change).
- Protected fields are the real coupling. Once a subclass reads one, the base can no longer change how it stores anything, and the encapsulation the base thought it had is gone (Exposing Too Much).
- The classic proof case:
Square extends Rectangleis true in geometry and false in code, becausesetWidthon a rectangle promises height is unchanged and a square cannot keep that promise.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The parser node set changes only when the language grammar changes, which is roughly annually and always as a deliberate versioned event.
- The customer types change whenever billing does, which is every sprint.
- The language has no traits or mixins, so shared behaviour is either a base class, a field, or a free function.
- Anywhere the code holds a base-typed value, every subtype must work — no caller may need to know which one it has (Liskov Substitution, Critically).
- A subtype must not weaken what the base promises: no narrowing what it accepts, no widening what it can throw, no removing a guarantee the base made.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The base owns the contract every subtype must satisfy, and nothing that only some subtypes need.
- Each subtype owns exactly the variation it names, and must not need to know what its siblings do.
- Nobody owns "a convenient place to put a helper". That is what a function is for (The Utility Dumping Ground).
- The boundary is the base class's public and protected surface, and it is a contract as binding as any published API — with the difference that you usually cannot see all its callers (Stable Boundaries).
- A closed subtype set is a boundary too: if the base and all its subtypes live in one module and nothing outside can extend it, the hierarchy can be changed wholesale in one edit. Sealed types make that enforceable (Internal Module Contracts).
- Once subtypes exist in other teams' code, that boundary is public and every protected member is now permanent (API Stability).
The three conditions, checked one at a time
Each row is a question with a concrete answer, not a judgement call. Run all three before writing extends; the parser hierarchy passes all three and the customer one fails two, and that difference is the entire lesson.
The third condition is the one people skip. "Shared behaviour" is usually asserted from the fact that the code looks the same, which is exactly the evidence that does not count.
| Condition | What to actually check | AST nodes | PremiumCustomer extends Customer |
|---|---|---|---|
| Substitutability | Name three call sites holding the base type. Does the subtype work unmodified at all three, with no instanceof? | Yes — the visitor, the printer and the span walker never ask which node they have. | No — reporting, invoicing and export all branch on tier within a page of receiving a Customer. |
| Closed and stable set | When did the set of subtypes last change, and who is allowed to add one? | Annually, with the grammar, by the compiler team only. Sealed. | Every sprint, by whoever is doing billing. Marketing has already asked for two more tiers. |
| Shared meaning, not shared lines | When the shared method changes, must every subtype change with it? | Yes — a span is a span; changing how spans merge must change everywhere at once. | No — premium address formatting diverged in the first release, which is what the override proved. |
| Verdict | All three, or none of it. | Inheritance, sealed, thin abstract base. Adding a node costs one file. | A tier field, or a PricingPolicy held by Customer. The subclass was a place to put a method (Composition Over Inheritance). |
What a base class that earns its place looks like
The parser base holds almost nothing: one field every node genuinely has, one abstract method every node genuinely implements, and no logic a subclass could be surprised by. That thinness is not stylistic — it is what keeps the base changeable, because there is nothing in it for a subclass to depend on.
Contrast the shape that goes wrong: a base with mutable protected state and a concrete method that calls another of its own methods. The moment a subclass overrides the inner one, the base's behaviour is defined by code the base author never saw.
1// Closed: every subtype lives in this file, nothing outside extends it.2abstract class Node {3 constructor(readonly span: Span) {}4 abstract accept<T>(v: Visitor<T>): T // the whole contract5}6 7class NumberLit extends Node { constructor(span: Span, readonly value: number) { super(span) }8 accept<T>(v: Visitor<T>) { return v.number(this) } }9 10class Add extends Node { constructor(span: Span, readonly l: Node, readonly r: Node) { super(span) }11 accept<T>(v: Visitor<T>) { return v.add(this) } }12 13// Adding a node: one class, and every Visitor implementation14// fails to compile until it handles it. That compiler error IS15// the feature — it is the exhaustiveness a field could not give you.No protected state, no concrete method calling an overridable one, no default anybody can inherit by accident. The base is a contract, not a code store.
The smell that means the subclass was a shortcut
The reliable signal is directional: specialisation adds, shortcuts subtract. A subtype that overrides a method to throw, to return a constant, or to do nothing has been given a contract it cannot honour, and every caller holding the base type is now wrong in a way the type system says is fine.
looks like An override whose body is throw new UnsupportedOperationException(), return null, or an empty block — often with a comment saying it does not apply to this subtype.
suggests The base contract is wider than the subtypes genuinely share. The class was extended to reuse the parts that fit, and the parts that did not were disabled rather than the relationship reconsidered.
fix Split the contract so nobody inherits what they cannot do (Interface Segregation, Critically), or replace the subtype relationship with a field holding the behaviour that actually varies. If exactly one method was wanted, extract it to a function and call it from both places.
NoOpMetrics that implements twenty methods as empty bodies is honest, complete, and exactly what is wanted. It is also fine in a stable optional-capability protocol where "not supported" is part of the documented contract and callers are required to check first (Optional Values and Absence).How to build it
Most important first.
- Apply the substitution test first, concretely: name three call sites that hold the base type, and check the subtype works at all three with the caller unmodified. If a caller would have to check the type, it is not a subtype.
- Require that the shared part is behaviour with shared *meaning*, not shared lines. Two methods with identical bodies and different owners are not a base class (Duplicate Knowledge).
- Prefer a closed set. Sealed classes, sum types or a single-module hierarchy let you add a case and have the compiler list every place that must handle it — which is the strongest thing inheritance offers (Making Illegal States Unrepresentable).
- Keep the base thin and abstract. Shared *state* in a base is the part that hurts; shared abstract signatures cost almost nothing.
- Make the base final in behaviour: document what a subclass may override and what it must call, or make the extension points explicit methods rather than "override anything" (Template Method).
- If you wanted the base only to reuse one method, extract that method to a function and call it from both. Reuse is not a reason to couple two lifecycles.
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Adding a subtype to a well-formed closed hierarchy is the cheapest change in this whole domain: one new file, and with exhaustive matching the compiler names every site that must handle it. Nothing existing is opened.
- Adding a *method* to the base is the expensive direction and gets worse with every subtype: either all twenty implement it, or the base supplies a default that is wrong for some of them silently.
- Changing the base's internals costs a full regression of every descendant, because subtypes depend on implementation, not interface. That cost is invisible in the diff.
- For the parser: "add a new expression node" costs one file forever. For the customer types: "premium addresses format differently" costs an override, then a second override, then an untangling — which is why the same construct is right in one and wrong in the other.
- A closed, sealed hierarchy buys exhaustiveness and gives up open extension — third parties cannot add a case, which is exactly what a plugin system needs (Plugin Architecture).
- A thin abstract base gives up the free defaults that made inheritance attractive in the first place; every subtype implements everything.
- Insisting on the substitution test slows down cases where a quick subclass would genuinely have been fine for the life of the code (When Design Does Not Pay).
What can go wrong
- The fragile base class: a safe-looking change inside the base breaks a subclass that had overridden one of the methods the base calls internally. Nothing in the base's own tests notices.
- Refused bequest: a subtype inherits a method it cannot implement and throws
UnsupportedOperation, converting a compile-time contract into a runtime surprise. - The hierarchy grows a fourth level "temporarily" to hold something two of five subtypes need, and the two who need it are now separated from the three who do not by an accident of ordering.
- The mitigation fails too: sealing the hierarchy makes it safe to change and also makes legitimate extension by another team impossible, which is how a sealed core acquires a parallel copy elsewhere.
- Every subtype depends on the base's implementation, not merely its interface — including field layout, construction order and which of its own methods the base calls internally.
- The base ideally depends on nothing about its subtypes. When it starts checking
instanceof, the hierarchy has inverted and should be a sum type or a strategy field. - A closed hierarchy in one module has zero external dependents and can be reshaped freely; an open one has an unknown number and cannot.
- "Inheritance is fine, then." It is fine under three conditions that are checkable and frequently absent. The failure is not using it; the failure is not checking (Composition Over Inheritance).
- "LSP means the subtype must behave identically." It means callers written against the base must keep working. A subtype may do more, be faster, or accept more — it may not promise less.
- "Interfaces are inheritance too, so the same warnings apply." Implementing an interface couples you to a contract; extending a class couples you to an implementation. The second is much stronger and is what this lesson is about (Interface Versus Implementation).
- "Deep hierarchies are the problem, so keep it to two levels." Depth correlates with pain but does not cause it. A two-level hierarchy with a fat stateful base is worse than a five-level one of pure abstract nodes.
- divergent-change
- duplicate-knowledge
Testing it, and how it ages
- Write the base-contract test once and run it against every subtype. If a subtype needs its own version of a base assertion, substitutability has already failed (Contract Tests).
- Test the subtype through a base-typed reference, not its concrete type, or the tests will not notice when substitutability breaks.
- For a closed set, an exhaustiveness test — or better, a compiler check — that every subtype is handled at every dispatch point.
- Property-based tests are unusually effective here: the base's invariants are properties every subtype must satisfy (Property-Based Testing).
- Hierarchies age well when the domain's taxonomy is fixed by something outside your control — a grammar, a file format, an instruction set, a UI toolkit's widget tree.
- They age badly when the taxonomy is a business classification, because businesses reclassify. Customer tiers, product categories and account types all get renamed and recombined.
- The signal that a hierarchy has aged out: subtypes start overriding to *disable* inherited behaviour rather than to specialise it.
- The endgame for a hierarchy that no longer fits is usually not a rewrite but delegation: keep the type, move the behaviour into a field, empty the base (Incremental Migration).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALSubstitutability, a stable subtype set and genuinely-shared meaning are the conditions in any language that has subtyping at all, including structural ones like Go and TypeScript where subtyping is inferred rather than declared.
- LANGUAGE-SPECIFICLanguages with sealed classes or sum types (Rust enums, Kotlin sealed, Scala ADTs, Java 17 sealed) make the closed-set condition machine-checked, which converts "add a case" from a risky change into an exhaustively-guided one. In Python or Ruby the same hierarchy is open by default and the compiler will never tell you which sites you missed, so the argument for keeping it small is stronger.
- CONTESTEDA strong minority position holds that implementation inheritance should be avoided outright and interface inheritance plus delegation used everywhere — Go took this position at the language level and its ecosystem shows no obvious deficit. The counter is that AST, widget-tree and protocol-frame hierarchies in Java, Rust and Scala codebases have been stable for decades and pay no maintenance tax, and a rule that forbids them is priced by people who do not maintain them.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Programming Languages & Runtime Internals — sealed hierarchies, exhaustive pattern matching and the expression problem: adding a case is cheap and adding an operation is expensive under inheritance, and the trade reverses under a sum type plus free functions.