Skip to content

Composition over inheritance, and the fragile base class

Inherit and you depend on how the base calls itself; compose small parts, inject what varies, and extend only where the class is checked.

Predict first

Before you read: predict

A payments ledger library, and the subclass your team wrote against its version 1.0, whose postAll stored the entries itself:

Under version 2.0, shown here, what does main.ts print?

Code for this question
// ledger.ts (the payments library, version 2.0)export interface Entry { id: string; cents: number }export class Ledger {  protected entries: Entry[] = [];  post(e: Entry): void { this.entries.push(e); }  postAll(es: Entry[]): void { for (const e of es) this.post(e); }  size(): number { return this.entries.length; }} // audited-ledger.ts (yours, unchanged since version 1.0)import { Ledger, type Entry } from './ledger';export class AuditedLedger extends Ledger {  posted = 0;  post(e: Entry): void { this.posted += 1; super.post(e); }  postAll(es: Entry[]): void { this.posted += es.length; super.postAll(es); }} // main.tsimport { AuditedLedger } from './audited-ledger';const l = new AuditedLedger();l.post({ id: 'p-1', cents: 500 });l.postAll([{ id: 'p-2', cents: 120 }, { id: 'p-3', cents: 80 }, { id: 'p-4', cents: 60 }]);console.log(l.posted, l.size());
Pick one

The 30-second version

A subclass reuses its base by inheriting it, so its overrides run wherever the base calls those methods on itself, and a base release can break it unchanged. Composition holds small parts and forwards calls to them; varying parts are injected, so a new combination is new arguments, not a new class. Extend where the class itself is checked.

Inherit and you depend on how the base calls itself; compose small parts, inject what varies, and extend only where the class is checked.

How it works

Step 1 of 14

Two ways to reuse. A subclass inherits its base class's members; a composed object holds small parts and calls them through their methods.
In the kitchen
  • the franchise card adds a shot to every espresso and a shot to every flat white → a subclass adding work to two methods (steps 1 and 3)

Analogy: A coffee chain's drink cards and a franchise's own card (hospitality)

Head office issues a card for every drink, and one franchise café writes its own "extra strong" card on top of them.

At the café In code
the franchise card adds a shot to every espresso and a shot to every flat white a subclass adding work to two methods (steps 1 and 3)
head office rewrites the flat white card to start from its espresso card; the franchise's flat whites now get two extra shots, though its own card never changed a self-call reaching the override, after a base release (steps 2–5)
another café adds its extra shot at the hand-off counter, once per cup, whatever the card does inside forwarding: the wrapper's work runs once per call (step 7)
the order slip has separate boxes for size, milk and extra shot; an oat-milk large with an extra shot is ticked boxes, not a new card injected parts: a combination is arguments (steps 9–10)
the lid-sealing machine takes only the chain's own cups, however alike another cup looks extend where the class is checked (step 13)

In real terms: a subclass shares its base's methods and runs its overrides inside them; a wrapper or a service with injected parts only calls what it holds.

Breaks down when: the barista reads the new card and stops at the second extra shot. Code runs both overrides without a pause; only a test that counts notices.

Contrasting case: a kiosk that sells one drink in one size has no extra shot to add and nothing to combine, so neither choice has work to do.

Examples, run for real

4.1 ExamHall EXH-2670: a reconnect that looked like typing

The proctor console's activity feed extends the attempts package's Attempt, whose changed hook runs after each answer:

// attempt.ts (@examhall/attempts)
export class Attempt {
  answers = new Map<string, string>();
  answer(q: string, text: string): void { this.answers.set(q, text); this.changed(q); }
  restore(saved: [string, string][]): void { for (const [q, text] of saved) this.answer(q, text); }
  protected changed(q: string): void {}
}

// tracked-attempt.ts (the proctor console)
import { Attempt } from './attempt';
export class TrackedAttempt extends Attempt {
  feed: string[] = [];
  protected changed(q: string): void { this.feed.push(`edited ${q}`); }
}
4.1 EXH-2670 a reconnect restores 3 saved answers: feed "edited q1, edited q2, edited q3" · the candidate typed none of them
4.1 EXH-2670 one answer the candidate types: feed "edited q4" · tsc no diagnostics

restore reuses answer, so the override ran for every restored answer, and the proctor saw edits the candidate never made (steps 2 and 4).

4.2 ExamHall EXH-2671 and MR !2970: the fifth exam type as a combination

ExamHall's exam types were a base class and three subclasses, and the exam board asked for a proctored practice exam:

// exams.ts (before MR !2970)
export class BaseExam { start(): string[] { return ['load questions']; } }
export class TimedExam extends BaseExam { start(): string[] { return [...super.start(), 'deadline in 60 min']; } }
export class ProctoredExam extends TimedExam { start(): string[] { return [...super.start(), 'webcam on']; } }
export class PracticeExam extends BaseExam { start(): string[] { return [...super.start(), 'answers shown after each question']; } }
4.2 EXH-2671 before: 4 classes · a fifth class extending ProctoredExam starts "load questions, deadline in 60 min, webcam on, answers shown after each question"
4.2 EXH-2671 a fifth class extending PracticeExam instead starts "load questions, answers shown after each question, webcam on" · the webcam step is then written in 2 classes

MR !2970 turns each axis into a part that the service is given:

// exam-service.ts (MR !2970)
export interface Part { start(steps: string[]): void }
export class ExamService {
  constructor(private parts: Part[]) {}
  start(): string[] { const steps = ['load questions']; for (const p of this.parts) p.start(steps); return steps; }
}
export const deadline = (min: number): Part => ({ start: (s) => { s.push(`deadline in ${min} min`); } });
export const webcam: Part = { start: (s) => { s.push('webcam on'); } };
export const showAnswers: Part = { start: (s) => { s.push('answers shown after each question'); } };

// the proctored practice exam
export const proctoredPractice = new ExamService([webcam, showAnswers]);
4.2 MR !2970 the 3 existing types from parts: the same start steps as their classes (3 of 3)
4.2 MR !2970 proctoredPractice starts "load questions, webcam on, answers shown after each question" · classes added for it 0 · tsc no diagnostics
4.2 MR !2970 every mix of the 3 parts, each in or out: 8 exam types, 8 distinct start-step lists, from 3 parts and no class per mix

Before, the new type had to extend one parent and copy the other's step (step 8); after, it is one more list of the same parts, and three parts cover all eight mixes (steps 9–10).

Try it

Answer first, then check. Nothing is saved.

Question 1 of 5

The proctor console's unread badge counts alerts as they enter its feed, a class from ExamHall's shared feed package:

Consoles run 2.x and 3.x today, and 4.0 beta next term. The badge test builds a BadgedFeed, calls push with one alert and then pushAll with three, and expects its unread count to be 4.

Which BadgedFeed passes on all three versions?

Code for this question
// feed.ts (@examhall/feed 2.x)export interface Alert { seat: string; kind: string }export class AlertFeed {  protected items: Alert[] = [];  push(a: Alert): void { this.items.push(a); }  pushAll(as: Alert[]): void { this.items.push(...as); }  size(): number { return this.items.length; }} // feed.ts in 3.x, where only pushAll differs  pushAll(as: Alert[]): void { for (const a of as) this.push(a); } // feed.ts in 4.0 beta, where only push differs  push(a: Alert): void { this.pushAll([a]); }
Pick one

Question 2 of 5

A video service gives each kind of channel its own player subclass:

A live sports channel is both live and DRM-protected. After start('m-1') and seek(30), its player's log must read licence m-1, stream m-1, seek refused.

Which design gives that?

Code for this question
// player.tsexport class Player {  log: string[] = [];  start(id: string): void { this.log.push(`stream ${id}`); }  seek(sec: number): void { this.log.push(`seek ${sec}`); }} // live-player.tsimport { Player } from './player';export class LivePlayer extends Player {  seek(sec: number): void { this.log.push('seek refused'); }} // drm-player.tsimport { Player } from './player';export class DrmPlayer extends Player {  start(id: string): void { this.log.push(`licence ${id}`); super.start(id); }}
Pick one

Question 3 of 5

A shop's ProductCard class component renders a product's photo, title and price row. The team needs a SaleCard: the same card with a "Sale" badge on the photo and the old price struck through beside the new one. An MR adds class SaleCard extends ProductCard, overriding its renderBadge() and renderPrice() methods, and makes the list header extend ProductCard too, to reach its formatPrice() helper.

Which review reply holds up?

Pick one

Question 4 of 5

A box office's waitlist map offers each freed seat to the next fan in the queue:

Version 2.0 of the library:

// seats.ts (booking library 2.0)
export class SeatMap {
  protected held = new Set<string>();
  hold(seat: string): void { this.held.add(seat); }
  release(seat: string): void { if (!this.held.has(seat)) return; this.held.delete(seat); }
  releaseAll(): void { for (const s of [...this.held]) this.release(s); }
  releaseRow(row: string): void { for (const s of [...this.held]) if (s.startsWith(row)) this.release(s); }
  count(): number { return this.held.size; }
}

On 1.4, cancelling a show with three held seats sends no offers; on 2.0 it sends three.

Which of 2.0's four changes, applied to 1.4 by itself, makes cancelShow send them?

Code for this question
// seats.ts (booking library 1.4)export class SeatMap {  private held = new Set<string>();  hold(seat: string): void { this.held.add(seat); }  release(seat: string): void { this.held.delete(seat); }  releaseAll(): void { this.held.clear(); }  count(): number { return this.held.size; }} // waitlist-seats.tsimport { SeatMap } from './seats';export class WaitlistSeatMap extends SeatMap {  offers: string[] = [];  release(seat: string): void { super.release(seat); this.offers.push(seat); }} // cancel.tsimport type { WaitlistSeatMap } from './waitlist-seats';export const cancelShow = (map: WaitlistSeatMap): number => { map.releaseAll(); return map.offers.length; };
Pick one

Question 5 of 5

A chat app's shared logger and its send-error handler:

The chat SDK's errors.ts must define RateLimited, which the SDK throws with seconds set to 30 when the server asks clients to slow down. onSendError should then return retry in 30s · logged.

Which definition gives that?

Code for this question
// logger.ts (shared by every service)export const logError = (e: unknown): string => (e instanceof Error ? 'logged' : 'skipped'); // send.tsimport { RateLimited } from './errors';import { logError } from './logger';export function onSendError(e: unknown): string {  if (e instanceof RateLimited) return `retry in ${e.seconds}s · ${logError(e)}`;  return logError(e);}
Pick one

Go deeper

Pro tips
  1. When handed a five-level hierarchy in an interview, list what each level adds and the axis it varies on before you propose a change, and say which test pins each leaf class's behaviour first. When: the curriculum row's question, "This class hierarchy is five levels deep. How would you flatten it?" Verify: nothing to run; this is interview advice, and it keys nothing. Status: ADVICE.
  2. noImplicitOverride catches a base method renamed under an override, not a base method that starts calling one. When: a reviewer suggests turning it on to make a subclass safe against base releases (step 6). Verify (RAN, tips.mjs):
    tip 2 changed() marked override, noImplicitOverride on: tsc no diagnostics · a reconnect restoring 3 answers still shows 3 edits
    tip 2 the base's hook renamed changed -> onChange: tsc TS4113 tracked-attempt.ts:4
Source

A web page can change, so every claim from these pages is labelled as of 2026-10-02, the date read; every TypeScript and JavaScript behaviour this lesson grades is also executed on typescript 6.0.3 and Node 24.19.0.

  • Legacy React docs, Composition vs Inheritance (as of 2026-10-02), the curriculum row's second source: https://legacy.reactjs.org/docs/composition-vs-inheritance.html (react-legacy-composition-vs-inheritance.txt, the article from its first paragraph to its last). The page itself says these docs are old and no longer updated. Step 12 rests on it ("At Facebook, we use React in thousands of components, and we haven’t found any use cases where we would recommend creating component inheritance hierarchies."), and P3 quotes it.
  • TypeScript Handbook, Classes (as of 2026-10-02): https://www.typescriptlang.org/docs/handbook/2/classes.html (ts-handbook-classes.txt, from "Class Members" to the end of the article). Teaching only (steps 1–2): "A derived class has all the properties and methods of its base class, and can also define additional members." It keys no item.
  • TSConfig reference, noImplicitOverride (as of 2026-10-02): https://www.typescriptlang.org/tsconfig/noImplicitOverride.html (tsconfig-no-implicit-override.txt). Teaching only (step 6): "When working with classes which use inheritance, it’s possible for a sub-class to get “out of sync” with the functions it overloads when they are renamed in the base class." Tip 2 executes it.
  • MDN, Error, section "Custom error types" (as of 2026-10-02): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error (mdn-error.txt). Teaching only (step 13): "You might want to define your own error types deriving from Error to be able to throw new MyError() and use instanceof MyError to check the kind of error in the exception handler."
  • The row's first source, Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), ch. 1, is a book that cannot be fetched as text, and keys nothing.

Checked against the tools and docs as of 2026-10-02.