Loading learning content...
Polymorphism is one of the most celebrated features of object-oriented programming. The ability to write code that works with any object in a type hierarchy—without knowing the specific type—enables:
But here's the critical insight: Polymorphism only works when LSP holds.
The moment a subtype fails to behave like its parent, polymorphic code breaks. You're forced to add type checks, special cases, and defensive code. The elegance of polymorphism degrades into a tangled mess of conditionals.
By the end of this page, you will understand the deep connection between LSP and polymorphism. You'll see how LSP violations destroy polymorphic abstractions and how LSP compliance enables the clean, extensible architectures that make OOP powerful.
Polymorphism promises that you can write code once and have it work with many types. Instead of:
if (shape is Circle) drawCircle(shape)
else if (shape is Rectangle) drawRectangle(shape)
else if (shape is Triangle) drawTriangle(shape)
...
You write:
shape.draw()
And it works for circles, rectangles, triangles—even shapes that don't exist yet.
The Open/Closed Principle in action:
Polymorphism enables the Open/Closed Principle (OCP): code that's open for extension but closed for modification. You can add new shape types without touching the drawing code. The system grows through addition, not modification.
But there's a catch:
This only works if every shape actually can be drawn. If you create a VirtualShape that throws an exception when draw() is called, the polymorphic abstraction shatters. The caller must now know that VirtualShape is different, defeating the entire purpose of polymorphism.
LSP is the principle that makes polymorphism trustworthy. Without LSP, polymorphism is just syntax—you can call methods on a base type, but you can't trust what happens. With LSP, polymorphism is reliable—you know that any subtype will honor the contract.
When a subclass violates LSP, polymorphic code that worked perfectly with the parent starts failing with the child. Let's examine the progression from clean polymorphism to corrupted code:
1234567891011121314151617181920212223242526
// Stage 1: Beautiful polymorphic codeinterface Repository<T> { save(entity: T): Promise<T>; findById(id: string): Promise<T | null>; delete(id: string): Promise<boolean>;} // Works perfectly with any Repository implementationasync function processEntity<T extends { id: string }>( repo: Repository<T>, entity: T): Promise<void> { // Trust that save() persists the entity await repo.save(entity); // Trust that findById() retrieves what we saved const found = await repo.findById(entity.id); if (!found) { throw new Error("Entity not found after save"); } console.log("Entity processed successfully");} // Works with DatabaseRepository, FileRepository, MemoryRepository...// Beautiful, extensible, testableNotice how one LSP violation forces defensive code, which makes it easier to add more violations (since the abstraction is already compromised), which forces more defensive code. The system degrades into a collection of special cases that's impossible to maintain.
One of the clearest symptoms of LSP violation is the presence of instanceof checks (or equivalent type testing) in polymorphic code.
In healthy polymorphism, code doesn't need to know concrete types:
instanceof checks scattered around12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// BAD: instanceof checks reveal broken polymorphism abstract class PaymentMethod { abstract process(amount: number): Promise<boolean>;} class CreditCard extends PaymentMethod { async process(amount: number) { return this.chargeCard(amount); } private async chargeCard(amount: number) { /* ... */ return true; }} class DebitCard extends PaymentMethod { async process(amount: number) { return this.chargeBank(amount); } private async chargeBank(amount: number) { /* ... */ return true; }} // Violates LSP: Crypto can't do refunds, requires confirmationclass CryptoPayment extends PaymentMethod { async process(amount: number) { throw new Error("Use processWithConfirmation() instead"); } async processWithConfirmation(amount: number, confirmCode: string) { // Actual implementation return true; }} // Client code forced into instanceof checkingasync function checkout(method: PaymentMethod, amount: number): Promise<boolean> { // The smell: we can't just call method.process() if (method instanceof CryptoPayment) { const code = await getConfirmationCode(); return method.processWithConfirmation(amount, code); } // For "normal" payment methods return method.process(amount);} // Every new payment method that doesn't fit the contract// requires updating checkout() - polymorphism destroyedThe fix: Redesign the hierarchy
When you find yourself writing instanceof checks, it's a sign that your type hierarchy doesn't represent behavioral compatibility. The solution is usually one of:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
// GOOD: Redesigned hierarchy - no instanceof needed interface PaymentContext { readonly amount: number; readonly metadata: Record<string, unknown>;} // Contract explicitly declares that context may contain additional requirementsabstract class PaymentMethod { /** * Process payment with given context * May request additional info via context.metadata * Returns result with success/failure and any messages */ abstract process(context: PaymentContext): Promise<PaymentResult>;} interface PaymentResult { success: boolean; transactionId?: string; message?: string; requiresConfirmation?: boolean; confirmationUrl?: string;} // All implementations conform to the same contractclass CreditCard extends PaymentMethod { async process(context: PaymentContext): Promise<PaymentResult> { const success = await this.chargeCard(context.amount); return { success, transactionId: 'cc-123' }; } private async chargeCard(amount: number) { return true; }} class CryptoPayment extends PaymentMethod { async process(context: PaymentContext): Promise<PaymentResult> { // If confirmation not yet provided, request it if (!context.metadata['cryptoConfirmCode']) { return { success: false, requiresConfirmation: true, confirmationUrl: '/crypto/confirm', message: 'Please confirm crypto transaction' }; } // Confirmation provided, process payment const success = await this.processWithConfirmation( context.amount, context.metadata['cryptoConfirmCode'] as string ); return { success, transactionId: 'crypto-456' }; } private async processWithConfirmation(amount: number, code: string) { return true; }} // Client code is now truly polymorphic - no instanceof!async function checkout(method: PaymentMethod, amount: number): Promise<boolean> { const context: PaymentContext = { amount, metadata: {} }; let result = await method.process(context); // Handle confirmation flow generically while (result.requiresConfirmation) { const confirmation = await getConfirmation(result); context.metadata['cryptoConfirmCode'] = confirmation; result = await method.process(context); } return result.success;}Many classic design patterns depend on LSP to function correctly. Without substitutability, these patterns break down:
| Pattern | LSP Requirement | What Breaks Without LSP |
|---|---|---|
| Strategy | All strategies must be interchangeable | Strategies need special handling; client can't switch freely |
| Template Method | Subclasses must not break the algorithm structure | Template's invariants violated; algorithm produces wrong results |
| Factory Method | All created objects must be substitutable | Client code needs type checks after factory returns |
| Decorator | Decorators must be substitutable for decoratees | Decorated objects break client expectations |
| Composite | Composites must work like leaves from outside | Clients must know if they have composite or leaf |
| Proxy | Proxy must be substitutable for real subject | Clients must know if they have proxy or real object |
| Command | All commands must be uniformly executable | Executor needs different logic for different commands |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// Strategy Pattern: LSP in Action interface CompressionStrategy { /** * Compresses data and returns compressed bytes * POSTCONDITION: returned data is decompressible * POSTCONDITION: decompress(compress(data)) === data */ compress(data: Buffer): Buffer; decompress(compressed: Buffer): Buffer;} // All strategies are substitutable - any can be swapped for anotherclass GzipStrategy implements CompressionStrategy { compress(data: Buffer): Buffer { // Gzip compression - always produces valid gzip data return gzipSync(data); } decompress(compressed: Buffer): Buffer { return gunzipSync(compressed); }} class LzmaStrategy implements CompressionStrategy { compress(data: Buffer): Buffer { // LZMA compression - different algorithm, same contract return lzmaCompress(data); } decompress(compressed: Buffer): Buffer { return lzmaDecompress(compressed); }} // Violating strategy - breaks the pattern!class BrokenStrategy implements CompressionStrategy { compress(data: Buffer): Buffer { // VIOLATION: Sometimes returns uncompressed data if (data.length < 1000) { return data; // Not actually compressed! } return gzipSync(data); } decompress(compressed: Buffer): Buffer { // This will fail for small uncompressed data return gunzipSync(compressed); // Error: not gzip format! }} // Context that uses strategies polymorphicallyclass FileArchiver { constructor(private strategy: CompressionStrategy) {} archive(files: Buffer[]): Buffer { // Trusts that strategy.compress() produces valid compressed data const compressed = files.map(f => this.strategy.compress(f)); return Buffer.concat(compressed); } // With BrokenStrategy, this may fail for some files! // Strategy pattern only works when all strategies are substitutable}If you can't implement a design pattern cleanly, you may have an LSP violation. Difficulty with Strategy, Composite, or Decorator often points to subtypes that don't truly share behavior. Use patterns as a litmus test for hierarchy quality.
One of the greatest benefits of LSP compliance is testability. When subtypes are truly substitutable, you can:
1. Test with mocks and fakes
Mocks and fakes are substitute implementations used in tests. They must be substitutable for real implementations—if they're not, your tests don't verify real behavior.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// LSP enables testing with mocks and fakes interface EmailService { /** * Sends an email and returns success status * POSTCONDITION: If returns true, email was queued for delivery */ send(to: string, subject: string, body: string): Promise<boolean>;} // Production implementationclass SmtpEmailService implements EmailService { async send(to: string, subject: string, body: string): Promise<boolean> { // Actually sends email via SMTP return await sendViaSmtp(to, subject, body); }} // Test fake that's LSP-compliantclass FakeEmailService implements EmailService { public sentEmails: Array<{to: string; subject: string; body: string}> = []; async send(to: string, subject: string, body: string): Promise<boolean> { // Records email but doesn't send // Still honors contract: returns true if "successfully processed" this.sentEmails.push({ to, subject, body }); return true; }} // Test fake that VIOLATES LSPclass BrokenFakeEmailService implements EmailService { async send(to: string, subject: string, body: string): Promise<boolean> { // VIOLATION: Always returns true, even for invalid emails // Real implementation would return false for invalid addresses // Tests pass but production would fail! return true; }} // Test using LSP-compliant fakedescribe('NotificationService', () => { it('sends welcome email on signup', async () => { const emailService = new FakeEmailService(); const notificationService = new NotificationService(emailService); await notificationService.onUserSignup({ email: 'user@test.com' }); // Because FakeEmailService is LSP-compliant, // we can trust this test reflects real behavior expect(emailService.sentEmails).toHaveLength(1); expect(emailService.sentEmails[0].subject).toContain('Welcome'); });});2. Share test suites across implementations
As we saw in the previous page, LSP enables contract testing. The same tests that verify the parent's behavior should pass for all children.
Let's examine how LSP enables polymorphism in real-world scenarios:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
// Real-world example: File system abstraction interface FileSystem { readFile(path: string): Promise<Buffer>; writeFile(path: string, data: Buffer): Promise<void>; exists(path: string): Promise<boolean>; delete(path: string): Promise<void>;} // Local file systemclass LocalFileSystem implements FileSystem { async readFile(path: string): Promise<Buffer> { return fs.readFile(path); } async writeFile(path: string, data: Buffer): Promise<void> { await fs.writeFile(path, data); } async exists(path: string): Promise<boolean> { try { await fs.access(path); return true; } catch { return false; } } async delete(path: string): Promise<void> { await fs.unlink(path); }} // Cloud storage (S3)class S3FileSystem implements FileSystem { constructor(private client: S3Client, private bucket: string) {} async readFile(path: string): Promise<Buffer> { const response = await this.client.send( new GetObjectCommand({ Bucket: this.bucket, Key: path }) ); return Buffer.from(await response.Body!.transformToByteArray()); } async writeFile(path: string, data: Buffer): Promise<void> { await this.client.send( new PutObjectCommand({ Bucket: this.bucket, Key: path, Body: data }) ); } async exists(path: string): Promise<boolean> { try { await this.client.send( new HeadObjectCommand({ Bucket: this.bucket, Key: path }) ); return true; } catch { return false; } } async delete(path: string): Promise<void> { await this.client.send( new DeleteObjectCommand({ Bucket: this.bucket, Key: path }) ); }} // In-memory file system (for tests)class MemoryFileSystem implements FileSystem { private files = new Map<string, Buffer>(); async readFile(path: string): Promise<Buffer> { const data = this.files.get(path); if (!data) throw new Error(`ENOENT: ${path}`); return data; } async writeFile(path: string, data: Buffer): Promise<void> { this.files.set(path, data); } async exists(path: string): Promise<boolean> { return this.files.has(path); } async delete(path: string): Promise<void> { this.files.delete(path); }} // Because all implementations are LSP-compliant,// this code works with ANY file systemclass DocumentProcessor { constructor(private fs: FileSystem) {} async processDocument(inputPath: string, outputPath: string): Promise<void> { if (!await this.fs.exists(inputPath)) { throw new Error('Input document not found'); } const content = await this.fs.readFile(inputPath); const processed = this.transform(content); await this.fs.writeFile(outputPath, processed); } private transform(data: Buffer): Buffer { // Process document... return data; }} // Use in production with S3const prodProcessor = new DocumentProcessor(new S3FileSystem(s3Client, 'my-bucket')); // Use in development with local filesconst devProcessor = new DocumentProcessor(new LocalFileSystem()); // Use in tests with memoryconst testProcessor = new DocumentProcessor(new MemoryFileSystem()); // ALL work identically because all FileSystems honor the contractNotice how the DocumentProcessor doesn't know or care which FileSystem it's using. It could be reading from your laptop, from AWS S3 across the internet, or from RAM. The abstraction is complete because every implementation honors the contract. This is LSP-enabled polymorphism at its finest.
LSP and polymorphism are inseparable. One enables the other; violating one destroys the other. Here's what we've learned:
instanceof is a symptom — Type checks in polymorphic code reveal broken hierarchiesModule Complete:
You've now completed Module 1 of the Liskov Substitution Principle chapter. You understand:
In the next module, we'll explore Behavioral Subtyping in depth—the formal rules for preconditions, postconditions, and invariants that govern how subtypes must relate to their parents.
You now have a comprehensive understanding of the Liskov Substitution Principle. You can explain its definition, cite Liskov's formulation, reason about behavioral contracts, and understand why LSP is the foundation of reliable polymorphism. In the modules ahead, you'll learn to apply LSP through behavioral subtyping rules, recognize the classic Rectangle-Square problem, and fix LSP violations in real code.