Appearance
9.2.4 — Inheritance
The two notifiers from 9.2.3 both need retries, because email servers and SMS gateways both fail intermittently. The retry code is fifteen lines and it is identical in both classes. So somebody does the obvious thing:
typescript
abstract class BaseNotifier implements Notifier {
async notify(to: CustomerId, message: NotificationMessage): Promise<void> {
for (let attempt = 1; attempt <= 3; attempt++) { // (1)
try {
await this.deliver(to, message); // (2)
return;
} catch (err) {
if (attempt === 3 || !isRetryable(err)) throw err;
await sleep(200 * 2 ** attempt); // (3)
}
}
}
protected abstract deliver(to: CustomerId, message: NotificationMessage): Promise<void>; // (4)
}
class EmailNotifier extends BaseNotifier { // (5)
protected async deliver(to: CustomerId, message: NotificationMessage): Promise<void> {
await this.smtp.sendMail(/* … */);
}
}Line (1) loops up to three times. Line (2) calls this.deliver, which does not exist in this class — it will resolve to whatever the actual object provides. Line (3) waits longer after each failure, which is exponential backoff (Part 10.9 covers why the doubling matters). Line (4) declares deliver as abstract, meaning BaseNotifier refuses to be created on its own and any subclass must supply this method. Line (5) is a subclass that supplies it.
This is a good use of inheritance. Hold onto it, because most of this page is about how the same tool goes wrong, and the difference between this example and the bad ones is precise rather than a matter of taste.
1. What extends actually does — two jobs in one keyword
class Child extends Parent does two completely separate things at once, and almost every problem with inheritance comes from wanting one of them and getting both.
Job one: subtyping. A Child may be used anywhere a Parent is expected. That is a promise to the type system and to every caller.
Job two: implementation reuse. Child gets Parent's method bodies and fields for free.
The first job is usually what you want. The second job is what causes the damage, because it creates a dependency on the parent's internals rather than on its contract — and that is the hidden, invisible coupling that 9.1 named the most expensive kind.
Mechanically, in JavaScript, extends sets up a chain. When you call notifier.notify(...), the engine looks for notify on the object itself, then on EmailNotifier.prototype, then on BaseNotifier.prototype, and stops at the first one it finds (3.6.4 covers the lookup in detail). In Java or C++ the machinery is a table of function pointers instead of a chain of objects, and the observable behaviour is the same. super.method() means "start the search one level higher than where you are now."
2. Construction runs parent-first, and it will bite you
Before the design lessons, one mechanical fact that produces a genuinely confusing runtime error.
When you create a subclass instance, the parent's constructor runs to completion before the child's field initializers run. TypeScript enforces the ordering by requiring super() as the first statement of a subclass constructor.
typescript
class Base {
constructor() {
this.setup(); // (1) calls an overridable method
}
protected setup(): void {}
}
class Child extends Base {
private items: string[] = []; // (2) field initializer
protected override setup(): void {
this.items.push("ready"); // (3)
}
}
new Child();
// → TypeError: Cannot read properties of undefined (reading 'push')Walk the order. Line (1) runs inside the parent constructor, and because this is already a Child, the lookup finds Child's setup. So line (3) executes. But line (2) has not run yet, because child field initializers run after the parent constructor finishes. this.items is undefined, and the push throws.
The rule that follows: never call an overridable method from a constructor. The parent has no way to know whether the subclass is ready to be called yet. If setup work is needed, do it in a separate method the caller invokes, or use a static factory that constructs and then initialises (9.2.1 section 5).
3. The fragile base class problem
This is the central reason the industry moved away from inheritance-for-reuse, and it is worth walking through slowly, because the failure is silent.
The notification team adds a metric. They want to count how many messages each channel sends, so they subclass:
typescript
class CountingEmailNotifier extends EmailNotifier {
count = 0;
override async deliver(to: CustomerId, message: NotificationMessage): Promise<void> {
this.count++; // (1)
await super.deliver(to, message); // (2)
}
}Line (1) counts, line (2) does the real work by calling up the chain. This works, the metric is correct, everybody is happy for a year.
Then a performance problem arrives. Sending one email at a time is slow, and the vendor supports batching. So the maintainer of BaseNotifier adds a bulk path:
typescript
abstract class BaseNotifier implements Notifier {
async notifyAll(to: CustomerId[], message: NotificationMessage): Promise<void> {
await this.deliverBatch(to, message); // (1) new fast path — does NOT call deliver()
}
protected async deliverBatch(to: CustomerId[], m: NotificationMessage): Promise<void> {
for (const one of to) await this.deliver(one, m); // (2) default: still calls deliver
}
}
class EmailNotifier extends BaseNotifier {
protected override async deliverBatch(to: CustomerId[], m: NotificationMessage) {
await this.smtp.sendBulk(to.map(/* … */)); // (3) one API call for all of them
}
}Line (1) adds a new entry point. Line (2) keeps a default that behaves as before. Line (3) is the optimisation: EmailNotifier overrides the batch path and sends everything in one call to the vendor, never touching deliver.
Now CountingEmailNotifier.count silently stops counting anything sent through notifyAll. Nothing failed to compile. No test broke, because the tests for the counter call notify directly. The metric is simply wrong, and it will stay wrong until somebody notices the dashboard does not match the vendor's invoice.
The precise diagnosis: the subclass was coupled to the base class's internal call structure, not to its public contract. BaseNotifier never promised that batch sends would route through deliver. It just happened to, and the subclass depended on it. The base class did nothing wrong, and the subclass broke.
That is the fragile base class problem. It is the tightest and most invisible coupling available in an object-oriented language, and extends produces it by default, because a subclass can see and depend on protected members and on override behaviour that no interface records.
Three properties make it worse than ordinary coupling. The dependency is undeclared, so no tool can check it. It is silent when broken, since overriding still compiles. And it is unbounded, because the base class author has no way to know which of their internal calls somebody is relying on, which means they cannot safely change anything.
If you must offer inheritance as an extension point, the fix is to make the contract explicit rather than accidental: state in the documentation which methods are called and when, mark everything else private rather than protected, and never change the self-call structure without treating it as a breaking change. That deliberate, documented version of this is the Template Method pattern (9.4.17), and the first snippet on this page is exactly that. BaseNotifier publishes one extension point, deliver, and promises to call it once per message.
4. The other three failure modes
Refused bequest. A subclass inherits methods it does not want and stubs them out:
typescript
class Stack extends ArrayList { // ❌ a stack "is-a" list? then...
// inherited: insertAt(index, item), removeAt(index), get(index)
}
stack.insertAt(0, x); // pushing to the middle of a stack. Legal. Meaningless.The tell is a subclass with methods that throw UnsupportedOperationError, or override to do nothing, or that you tell colleagues "just do not call". Every one of those says the same thing: this is not really an is-a relationship, and the class needed a List inside it rather than to be one.
Taxonomies that lie. class Square extends Rectangle is the standard example, and it is worth seeing the failure:
typescript
const r: Rectangle = new Square(5);
r.setWidth(10);
r.setHeight(4);
console.log(r.area()); // → 16 for a Square, 40 for a RectangleAny code written against Rectangle reasonably assumes width and height move independently. A square cannot honour that, so a Square is not substitutable for a Rectangle even though every geometry teacher says a square is a rectangle. The lesson is that inheritance is about behavioural substitutability, not about real-world categories, and 9.3.7 develops this into the Liskov Substitution Principle properly.
Deep hierarchies. Four or five levels of extends means that understanding one method requires reading five files and knowing which level overrides what. Every new subclass has to satisfy five sets of assumptions. Keep hierarchies one or two levels deep. If a third level is arriving, the second level probably wanted composition.
5. When inheritance is genuinely the right tool
The industry advice — prefer composition — is correct as a default and wrong as an absolute. Here are the cases where extends is the honest choice, and they share one property: substitution is the point, and the hierarchy is stable.
Framework extension points. When you write class OrderController extends BaseController, the framework calls you rather than the other way round. The base class provides a skeleton with named holes, and filling holes is exactly what a subclass is for. The contract is published and versioned by the framework author.
Abstract classes that define a skeleton with holes. The BaseNotifier at the top of this page. The base owns the algorithm shape — try, catch, back off, retry — and the subclass owns one step. This works because the extension point is deliberate and documented, and because the abstract method makes it impossible to forget.
Error hierarchies. This is the case where inheritance is not just acceptable but the best available tool:
typescript
class AppError extends Error {
constructor(message: string, readonly cause?: unknown) { super(message); }
}
class ValidationError extends AppError {} // (1)
class NotFoundError extends AppError {}
class InsufficientFundsError extends AppError {
constructor(readonly needed: Money, readonly available: Money) { // (2)
super(`needed ${needed} but only ${available} available`);
}
}
try { await wallet.spend(total); }
catch (err) {
if (err instanceof InsufficientFundsError) return topUpPrompt(err.needed); // (3)
if (err instanceof AppError) return badRequest(err.message); // (4)
throw err; // (5)
}Line (1) creates a category. Line (2) attaches data specific to one failure, so the handler can do something useful rather than just print a string. Line (3) matches the specific error and line (4) matches the whole family with one check, which is exactly the substitutability that inheritance provides and nothing else does as cleanly. Line (5) rethrows anything unrecognised, which is the habit that keeps genuine bugs from being swallowed as if they were expected failures.
Sharing genuine invariant-preserving machinery between variants that really are the same kind of thing, where the base class enforces something all of them must obey.
Two working rules cover all of it:
Inherit for substitutability, never for reuse alone. If you are extending a class only because it already has a method you want, you wanted composition.
If you override a method to do less than the parent — to throw, to do nothing, to ignore an argument — you had the relationship backwards.
6. abstract and the tools TypeScript gives you
typescript
abstract class BaseNotifier {
abstract deliver(to: CustomerId, m: NotificationMessage): Promise<void>; // (1)
protected shouldRetry(err: unknown): boolean { // (2)
return isNetworkError(err);
}
async notify(to: CustomerId, m: NotificationMessage): Promise<void> { /* … */ } // (3)
}Line (1) is an abstract method: no body, and every concrete subclass must provide one. new BaseNotifier() is a compile error, which is the point — the base is incomplete by design.
Line (2) is a protected method with a default, so subclasses may change it and outside callers cannot see it. Be deliberate here. Everything protected is part of your contract with every subclass forever, so the default should be private and protected should be a decision you can defend.
Line (3) is a concrete method the subclass inherits and should not override, which brings up a real gap: TypeScript has no final keyword, so you cannot stop a subclass from overriding something. What you do have is override, which is the opposite check:
typescript
class EmailNotifier extends BaseNotifier {
override async delivr(to: CustomerId, m: NotificationMessage) { /* … */ }
// → error: This member cannot have an override modifier because it is not
// declared in the base class. (typo caught at compile time)
}With noImplicitOverride turned on in tsconfig (3.7.1), every override must say so. That single flag catches the two classic accidents: a typo that silently creates a new method instead of overriding, and a base class removing a method that subclasses still think they are overriding.
Multiple inheritance does not exist here, and that is deliberate. TypeScript, JavaScript, Java and C# all allow only one parent class. The reason is the diamond problem: if C extends both A and B, and both define save(), which one runs? Languages that allow it, like C++ and Python, need explicit rules to resolve the ambiguity, and those rules become their own source of confusion. What you get instead is: implement as many interfaces as you like, because interfaces carry no bodies and therefore no ambiguity. When you genuinely need behaviour from two sources, use composition or mixins, which 9.2.8 covers.
7. The default to work from
Put the whole page into one decision procedure you can run in a review.
Start with composition. Give the class a collaborator instead of a parent. It is strictly more flexible: you can hold several, swap them at runtime, and the coupling runs through a small visible interface.
Move to inheritance only when all three of these are true. There is a genuine is-a relationship where the subclass can be used anywhere the parent can with no surprises. The hierarchy is stable, meaning you are not going to be adding sibling categories every quarter. And the extension points are deliberate and documented, not accidental.
Then keep it small. One or two levels. Abstract base classes rather than concrete ones, because a concrete base invites extends for reuse. private by default and protected only where you mean it. Never call an overridable method from a constructor. Document what calls what if subclasses are expected to override anything.
What the next page adds. Inheritance and interfaces both let one call site run different code depending on the object. 9.2.5 is about that mechanism — how the right method gets found, what it costs, and why "dispatch replaces selection" is the most economically useful sentence in object-oriented design.
Recall
extendsbundles two jobs: subtyping, which is usually what you want, and implementation reuse, which is where designs rot. The reuse half couples you to the parent's internals rather than to its contract.- Construction runs parent-first: the parent constructor completes before child field initializers run. So calling an overridable method from a constructor can reach a subclass whose fields are still
undefined. Never do it. - Fragile base class: a subclass depends on which internal methods the parent happens to call. The parent legally changes its internals and the subclass silently breaks — undeclared, silent, and unbounded, so no tool can catch it.
- Other failure modes: refused bequest (subclass stubs out methods it does not want), taxonomies that lie (
Square extends Rectanglebreaks callers who expect width and height to move independently), and hierarchies deeper than two levels. - Inheritance is right when substitution is the point and the hierarchy is stable: framework extension points, an abstract skeleton with documented holes (Template Method), and error hierarchies where
instanceofon a family is exactly what you want. - TypeScript gives you
abstract(no body, must be implemented, cannot be created) andoverridewithnoImplicitOverride(catches typos and removed base methods). There is nofinal. Only one parent class is allowed, which avoids the diamond problem; implement as many interfaces as you like. - Two rules: inherit for substitutability, never for reuse alone, and if you override to do less, you wanted composition.
Self-test: Name the two jobs extends does and say which one causes trouble. Reproduce the fragile base class failure from memory, including why nothing failed to compile. Why does calling an overridable method from a constructor throw? Give three cases where inheritance is the right tool and say what they have in common. What does override catch that nothing else does?
Quiz Bank
FoundationalWhat two things does extends actually give you, and why does bundling them cause problems?
Subtyping: a Child can be used anywhere a Parent is expected, which is a promise to every caller. Implementation reuse: Child inherits the parent's method bodies and fields.
The bundling is the problem because you usually want one of them and always get both. When you want subtyping — an error hierarchy, a framework extension point — the reuse comes along and quietly creates a dependency on the parent's internals. When you want reuse — some shared retry code — the subtyping comes along and declares to the whole program that these two things are interchangeable, which may not be true.
Why depending on internals is the expensive part. A caller of a public method depends on a contract that the author knows they must keep. A subclass depends on the parent's self-call structure: which internal method calls which, in what order, how many times. Nothing records that, so the parent's author cannot know they must keep it, and cannot avoid breaking it.
Interfaces separate the two jobs cleanly, which is why "program to an interface" and "prefer composition" are two halves of the same advice: interfaces give you substitutability without inheriting anybody's internals, and composition gives you reuse without claiming to be the same kind of thing.
AppliedExplain the fragile base class problem with a concrete failure and the design conclusion it forces.
A subclass that overrides a method is coupled not only to its parent's interface but to its self-call structure — which internal methods the parent calls, in what order.
The failure. BaseNotifier.deliverBatch loops calling this.deliver once per recipient. CountingNotifier extends it and overrides deliver to increment a counter, so batch sends get counted too, by accident of the parent's internals. Later the parent adds a genuine bulk path that calls the vendor's batch API directly and never touches deliver. That change is entirely legal: the public contract never promised anything about internal routing. It compiles cleanly, every existing test passes, and the counter silently stops counting batch sends. The bug surfaces weeks later as a dashboard that disagrees with the vendor's invoice.
Why this is worse than ordinary coupling. It is undeclared, so no signature and no tool records it. It is silent, because overriding a method that is no longer called still compiles. And it is unbounded, because the parent's author has no way to know which internal calls somebody depends on, so they cannot change anything safely.
The conclusion. Using extends for reuse creates the tightest invisible coupling the language offers, so compose for reuse — hold a collaborator behind a small interface, where the coupling is visible and checked. Reserve inheritance for genuine substitutability with shallow hierarchies. Where you do want subclasses to plug into an algorithm, make the contract deliberate: publish which methods are called and when, keep everything else private rather than protected, and treat a change to the self-call structure as a breaking change. That deliberate form is Template Method (9.4.17).
InterviewWhen is inheritance the right choice? Give the cases and what they have in common.
Framework extension points. extends React.Component, extends BaseController, a test-case base class. The framework calls you, so a declared skeleton with named holes is the honest shape, and the framework author owns and versions the contract.
An abstract class defining an algorithm with deliberate holes. A retrying notifier where the base owns try-catch-backoff-repeat and the subclass owns one step. The extension point is documented, and abstract makes it impossible to forget to implement.
Error hierarchies. class ValidationError extends AppError lets one catch handle a whole family with err instanceof AppError while a more specific handler matches InsufficientFundsError and reads its typed fields. Nothing else gives you that grouping as cleanly, and it is genuinely substitutable, since anywhere an AppError is handled a ValidationError is handled correctly.
Genuine invariant-sharing between true variants, where the base enforces something every subclass must obey.
What they have in common: substitution is the actual point, and the hierarchy is stable. In each case you are declaring "these are interchangeable kinds of the same thing", which is exactly what subtyping means, and the set of kinds is not going to be restructured every quarter. None of them is "I extended it because it already had a method I needed", which is the case that always turns out badly.
The rules of engagement to state alongside the cases: one or two levels deep, private by default with protected as a considered decision, never call an overridable method from a constructor, and if you find yourself overriding a method to do less than the parent, the relationship was backwards and you wanted composition.
StaffA five-level class hierarchy for report generation has 40 classes. Adding a report takes two days and often breaks another. Lay out a migration.
The diagnosis first, because the shape tells you the cure. Forty classes across five levels almost always means the hierarchy is encoding a combination rather than a category. Reports vary along independent axes — data source, format, delivery, schedule — and inheritance can only express one axis, so every combination becomes a class and the count multiplies. That is class explosion, and the two-day cost per report is the hierarchy forcing you to find the right insertion point and satisfy five levels of assumptions.
"Adding one breaks another" identifies the second problem: shared mutable behaviour in the middle levels, where subclasses depend on the self-call structure of their ancestors. Every insertion is a fragile-base-class risk.
The migration, which must be incremental because a forty-class rewrite will not be approved and should not be.
Step one, name the axes. Read ten of the leaf classes and write down what actually varies. Usually three or four axes fall out immediately, and often one axis has only one real value, which means it is not an axis at all.
Step two, pin the behaviour. Characterisation tests over the existing outputs, ideally by capturing real generated reports as fixtures. You cannot safely restructure what you have not pinned down (Chapter 9.8).
Step three, extract one axis at a time, starting with the leaves. Take the axis with the most values, usually format, and turn it into a role: interface ReportFormatter { format(data: ReportData): Buffer }. Convert the leaf classes to hold a formatter instead of being a formatted subclass. This is strangling the hierarchy from the bottom, and each step is independently shippable and reviewable.
Step four, repeat until the middle levels are empty, then delete them. The hierarchy collapses from the inside rather than being replaced in one move.
Step five, the end state, which is worth describing up front so the team can see where they are going: one concrete Report class holding a source, a formatter and a delivery, plus a small class per value on each axis. Three axes with four values each become twelve small classes covering sixty-four combinations, instead of sixty-four classes. Adding a format is one new class and one registry line, and it cannot break any other report because nothing else is touched.
What to promise and what not to. Promise that each step is small, reversible and shippable, and that the cost of adding a report drops measurably at each step. Do not promise a big-bang rewrite date, and do not migrate reports nobody has run in two years — check the logs, because the cheapest migration is deleting them.