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?
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);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
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?
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: 'p9@hall.example' };export const sent = channels.map((c) => c.send(proctor));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?
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?
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?
// 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');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?
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' }; }}Go deeper
Pro tips
- 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).
- Before you merge an override, run the base class's own tests against the subclass: the test sees a broken promise that
tsccannot. 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.
- Robert C. Martin, Design Principles and Design Patterns (2000), the curriculum row's first source, from the author's own company site: https://objectmentor.com/resources/articles/Principles_and_Patterns.pdf (
martin-2000-principles-and-patterns.txt, text by pdftotext). - Robert C. Martin, Getting a SOLID start (12 February 2009), which points readers to the 2000 paper: https://sites.google.com/site/unclebobconsultingllc/getting-a-solid-start (
martin-2009-getting-a-solid-start.txt, the post without its reader comments). - Robert C. Martin, Solid Relevance (18 October 2020), the author's one-line statement of each principle: https://blog.cleancoder.com/uncle-bob/2020/10/18/Solid-Relevance.html (
martin-2020-solid-relevance.txt). - TypeScript Handbook, Classes (as of 2026-10-01): https://www.typescriptlang.org/docs/handbook/2/classes.html (
ts-handbook-classes.txt). Teaching only (step 7): "It's important that a derived class follow its base class contract." It keys no item. - TypeScript 2.6 release notes, Strict function types (as of 2026-10-01): https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-6.html (
ts-2-6-release-notes.txt). Teaching only (step 8): "The stricter checking applies to all function types, except those originating in method or constructor declarations." - The row's second source, Martin's Clean Architecture (2017), is a book that cannot be fetched as text, and keys nothing.
Related lessons
- Related: Single responsibility as one reason to change
- Related: Open/closed through the strategy pattern
- Related: Dependency inversion and NestJS dependency injection
- Related: When a pattern is over-engineering
- Related: Structural typing, interfaces and declaration merging
- Related: Types vanish at runtime, validate at the boundary
- Related: Test doubles and boundaries
- Related: Component decomposition and client data model
Checked against the tools and docs as of 2026-10-01.