Skip to content

SOLID at a glance, with LSP and ISP in TypeScript

Use the five principles as heuristics; the compiler checks shapes, so a subtype's broken promise and a fat interface's coupling are yours to see.

Predict first

Before you read: predict

A payments ledger runs this fee job. The file is compiled with tsc under strict, and the emitted JavaScript is run.

What happens?

Code for this question
export class Account {  constructor(public id: string, protected balance: number) {}  // Takes the amount off the balance and returns the new balance.  debit(amount: number): number {    this.balance -= amount;    return this.balance;  }}export class FrozenAccount extends Account {  debit(amount: number): number {    throw new Error(`account ${this.id} is frozen`);  }} export function chargeFee(accounts: Account[], fee: number): void {  for (const a of accounts) a.debit(fee);} export const accounts = [new Account('a-1', 50), new FrozenAccount('a-2', 80), new Account('a-3', 20)];chargeFee(accounts, 5);
Pick one

The 30-second version

SOLID names five heuristics, not rules: one reason to change, extend without editing, keep the base's promises, small client-specific interfaces, depend on abstractions. tsc checks an override's signature, not its promise: one that throws where the base returns compiles, runs in the base's place and breaks its callers. One large interface ties every client to every method.

Use the five principles as heuristics; the compiler checks shapes, so a subtype's broken promise and a fat interface's coupling are yours to see.

How it works

Step 1 of 14

Heuristics, not rules. Martin calls the five principles heuristics: observed to work in many cases, with no proof they should always be followed.

Analogy: A car-hire branch and its rental agreements (vehicle rental)

A customer books "group B or similar" and plans the trip from what group B promises: five seats, motorway speed, a boot for two cases.

At the car-hire branch In code
any car handed over for a group B booking must keep group B's promises; a bigger boot is fine, a smaller one is not a subtype may accept more and deliver more, never less (steps 4–5)
a group B car whose boot will not open passes the desk's paperwork, which checks only the group letter, and fails at the kerb an override that throws passes tsc, which checks only signatures (steps 6–7)
staff who learn of it keep a note: "if it's that car, warn the customer" instanceof branches in callers (step 9)
one 20-page agreement for cars, vans, minibuses and trailers, re-signed by every car renter when the towing clause changes a fat interface: any change reaches every client (steps 11–12)
a car agreement and a van agreement, with the fuel policy in both one interface per kind of client, a shared method in each (step 13)
a separate agreement written for every customer the overdone version (step 14)

In real terms: tsc compares an override's parameter and return types with the base's and never reads its body; a client typed against an interface must be given, and faked with, every member of it.

Breaks down when: the desk can lift the boot lid before handing over the keys. Nothing in TypeScript looks inside a method for a broken promise; only running it, or a test, does.

Contrasting case: a firm with one car model and one kind of customer has nothing to substitute and nothing to segregate, so neither principle has work to do there (step 2).

Examples, run for real

4.1 ExamHall EXH-2500: a read-only attempt in the autosave flush

A proctor reviewing a closed exam reopens an attempt as ReadOnlyAttempt, whose save() throws. The autosave flush is typed against Attempt, and its queue of five holds the review attempt third:

export const store = new Map<string, string[]>();

export class Attempt {
  constructor(public id: string, protected answers: string[]) {}
  // Stores the answers and returns how many were saved.
  save(): number { store.set(this.id, [...this.answers]); return this.answers.length; }
}
export class ReadOnlyAttempt extends Attempt {
  save(): number { throw new Error(`attempt ${this.id} is closed for review`); }
}

export function flush(queue: Attempt[]): number {
  let saved = 0;
  for (const a of queue) saved += a.save();
  return saved;
}
4.1 EXH-2500 ReadOnlyAttempt extends Attempt: tsc no diagnostics · flush threw Error · stored a-1, a-2 · not stored a-4, a-5
4.1 MR !2800 both implement AttemptView, only Attempt has save(): tsc TS2741 queue.ts:2 (the line that queues a-3)

The override kept the signature, so tsc passed it; at run time it ran in the base's place, and the flush stopped there (steps 6–7). MR !2800 stops claiming the subtype: a read-only attempt is an AttemptView, which has no save(), and tsc now rejects queueing it (step 10).

4.2 ExamHall EXH-2501: a 20-method port behind a 2-method badge

The proctor console's ProctorPort has 20 methods. alertBadge calls two of them, and its test builds a fake with those two:

export interface ProctorPort {
  onAlert(cb: (seat: string) => void): void;  unreadCount(): number;    markRead(id: string): void;
  pauseExam(examId: string): void;            resumeExam(examId: string): void;  extendTime(seat: string, min: number): void;
  flagSeat(seat: string, why: string): void;  clearFlag(seat: string): void;     messageSeat(seat: string, text: string): void;
  broadcast(text: string): void;              lockBrowser(seat: string): void;   unlockBrowser(seat: string): void;
  admit(seat: string): void;                  eject(seat: string): void;         seatPhoto(seat: string): string;
  roster(examId: string): string[];           incidents(examId: string): string[];  exportLog(examId: string): string;
  handOver(toProctor: string): void;          endExam(examId: string): void;
}
// alert-badge.ts: alertBadge(port: ProctorPort) calls port.onAlert and port.unreadCount
// alert-badge.test.ts: const fake = { onAlert: (cb) => cb('S-12'), unreadCount: () => 3 }, typed like the parameter
4.2 EXH-2501 alertBadge(port: ProctorPort), fake with 2 methods: TS2740 alert-badge.test.ts:3 (the fake lacks the other 18)

Typed against the whole port, the badge depends on 18 methods it never calls, and so does its fake (steps 11–12). MR !2801 applies step 13 to the badge.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 5

ExamHall sends notices to candidates and proctors, and a developer adds an SMS channel. The file is compiled with tsc under strict, and the emitted JavaScript is run:

What happens?

Code for this question
type Candidate = { kind: 'candidate'; id: string; phone: string };type Proctor = { kind: 'proctor'; id: string; email: string };export type Recipient = Candidate | Proctor; export class Notice {  // Delivers a notice to any recipient and says where it went.  send(to: Recipient): string {    return `${to.kind} ${to.id}`;  }}export class SmsNotice extends Notice {  send(to: Candidate): string {    return `sms ${to.phone}`;  }} const channels: Notice[] = [new Notice(), new SmsNotice()];const proctor: Proctor = { kind: 'proctor', id: 'p-9', email: '[email protected]' };export const sent = channels.map((c) => c.send(proctor));
Pick one

Question 2 of 5

A shop's CatalogService class has eight methods. The product page and the search page call only title and price; the stock dashboard calls only stock, reorder and price. The team will type each client against an interface instead of the class.

Which set of interfaces fits Martin 2000's advice?

Pick one

Question 3 of 5

ExamHall's tech lead proposes a CI check for the NestJS API, citing SOLID: the build fails on any service class that does not sit behind an interface of its own.

Which reply to the proposal holds up?

Pick one

Question 4 of 5

A box-office app has these files:

A later MR will make refund return Promise<boolean>.

Which one change compiles now and keeps that MR out of every seat-map file?

Code for this question
// venue.tsexport interface Venue {  seats(show: string): string[];  layout(show: string): string;  hold(seat: string): string;  refund(holdId: string): boolean;} // seat-map.tsimport type { Venue } from './venue';export function seatMap(v: Venue, show: string): string {  return `${v.layout(show)}: ${v.seats(show).length} free`;} // checkout.tsimport type { Venue } from './venue';export function checkout(v: Venue, seat: string): string { return v.hold(seat); }export function cancel(v: Venue, holdId: string): boolean { return v.refund(holdId); } // seat-map.test.tsimport type { Venue } from './venue';import { seatMap } from './seat-map';const fake: Venue = { seats: () => ['A1', 'A2'], layout: () => 'stalls', hold: () => 'h-1', refund: () => true };export const shown = seatMap(fake, 's-1');
Pick one

Question 5 of 5

A chat app's ChatSender interface and four implementations:

A caller written against ChatSender may be handed any of the four.

Which one can break such a caller?

Code for this question
export interface Receipt { id: string }export interface ChatSender {  // Sends any text of 1 to 500 characters and returns its receipt.  // Throws RangeError for an empty text.  send(text: string): Receipt;}let n = 0;const nextId = () => `m-${++n}`;export const auditLog: string[] = []; export class AuditedSender implements ChatSender {  send(text: string): Receipt {    if (text.length === 0 || text.length > 500) throw new RangeError('length');    auditLog.push(text);    return { id: nextId() };  }}export class SmsSender implements ChatSender {  send(text: string): Receipt {    if (text.length === 0 || text.length > 160) throw new RangeError('length');    return { id: nextId() };  }}export class BulkSender implements ChatSender {  send(text: string): Receipt {    if (text.length === 0 || text.length > 4000) throw new RangeError('length');    return { id: nextId() };  }}export class ThreadSender implements ChatSender {  send(text: string): Receipt & { threadId: string } {    if (text.length === 0 || text.length > 500) throw new RangeError('length');    return { id: nextId(), threadId: 't-1' };  }}
Pick one

Go deeper

Pro tips
  1. In an interview, explain each principle with a bug it prevented in your own code; a definition without an example reads junior. When: "Explain SOLID with examples from your own code." Verify: nothing to run; this is the curriculum row's pro tip, and it keys nothing. Status: ADVICE (curriculum row).
  2. Before you merge an override, run the base class's own tests against the subclass: the test sees a broken promise that tsc cannot. When: reviewing a new subclass or a new implementation of an interface (steps 6–7). Verify (RAN, tips.mjs):
    tip 2 tsc on Attempt, ReadOnlyAttempt and Attempt's tests: no diagnostics
    tip 2 Attempt's 2 tests: on Attempt 2 pass · on ReadOnlyAttempt 0 pass
Source

The TypeScript documentation changes with releases, so its claims are labelled as of 2026-10-01; every TypeScript behaviour this lesson grades is also executed on typescript 6.0.3.

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