Loading content...
3 AM. Your pager goes off. Production is down.
You pull up the logs and see a cascade of UnsupportedOperationException errors originating from your document export pipeline. The stack trace shows:
DocumentExporter.exportAll() →
Document.export() →
ReadOnlyDocument.export() → THROWS UnsupportedOperationException
You designed the exporter to work with any Document. The ReadOnlyDocument class extends Document. So why is it throwing exceptions when export is called?
The answer lies in a subtle but devastating pattern: a subclass that cannot fulfill its superclass contract but was allowed to exist anyway. The ReadOnlyDocument can't be exported because... well, it was never meant to be. Someone created it to represent documents in a preview mode, and they overrode export() to throw an exception.
The hierarchy compiled. Tests passed. But the Liskov Substitution Principle was violated. And at 3 AM, that violation became your problem.
By the end of this page, you will understand how unexpected exceptions from subclasses constitute LSP violations, recognize the patterns that lead to exception-throwing overrides, and learn systematic approaches to designing hierarchies where subclasses never need to refuse operations their superclass promises.
When a base class declares a method, it establishes an implicit contract: this operation is meaningful for all instances of this type. Clients write code that calls this method, trusting it will "work" according to the method's documented purpose.
When a subclass overrides that method to throw an exception—signaling "I can't do this"—it breaks this contract. The subclass is essentially saying: "I inherit the interface that promises this capability, but I don't actually have it."
12345678910111213141516171819202122232425262728293031323334353637383940414243
// Base class establishes a contract: all Documents can be savedabstract class Document { abstract getContent(): string; abstract save(): void; // Contract: saving persists the document} // LocalDocument fulfills the contractclass LocalDocument extends Document { private content: string = ""; getContent(): string { return this.content; } save(): void { // Actually saves to file system - contract fulfilled fs.writeFileSync(this.path, this.content); }} // ReadOnlyDocument VIOLATES the contractclass ReadOnlyDocument extends Document { getContent(): string { return this.content; } save(): void { // VIOLATION: Throws instead of saving throw new Error("Cannot save a read-only document"); }} // Client code written against the abstractionclass AutosaveManager { private documents: Document[] = []; saveAll(): void { for (const doc of this.documents) { // Trusts that all Documents can be saved doc.save(); // Explodes when doc is ReadOnlyDocument! } }}A ReadOnlyDocument that throws on save() is a contradiction in terms. By extending Document, it claims to be a Document—which implies it can be saved. By throwing on save(), it admits it cannot. This internal contradiction is the essence of an LSP violation.
The hierarchy lies to its clients. The type system says "ReadOnlyDocument is a Document" and "Documents can save()". Both statements are technically true in the type system. But behaviorally, the combination is false—ReadOnlyDocuments cannot save, even though their type says they can.
This mismatch between declared type capabilities and actual runtime behavior is what makes exception-throwing overrides such dangerous LSP violations.
Exception-throwing overrides appear in predictable patterns. Recognizing these patterns helps you identify LSP violations during code review and design discussions.
UnsupportedOperationException or equivalent123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
// PATTERN 1: The Immutable Subclassclass MutableList<T> { protected items: T[] = []; add(item: T): void { this.items.push(item); } remove(item: T): void { const idx = this.items.indexOf(item); if (idx >= 0) this.items.splice(idx, 1); }} class ImmutableList<T> extends MutableList<T> { override add(item: T): void { throw new Error("Cannot modify immutable list"); // VIOLATION } override remove(item: T): void { throw new Error("Cannot modify immutable list"); // VIOLATION }} // PATTERN 2: The Stripped-Down Versionabstract class Vehicle { abstract accelerate(): void; abstract brake(): void; abstract reverse(): void;} class Bicycle extends Vehicle { accelerate(): void { /* pedal faster */ } brake(): void { /* apply brakes */ } reverse(): void { // Bicycles can't really reverse while moving throw new Error("Bicycles cannot reverse"); // VIOLATION }} // PATTERN 3: The Precondition Strengthenerclass NumberProcessor { process(value: number): number { return value * 2; // Works for any number }} class PositiveNumberProcessor extends NumberProcessor { override process(value: number): number { if (value <= 0) { throw new Error("Value must be positive"); // VIOLATION } return value * 2; }} // PATTERN 4: Mode-Dependent Exceptionsclass Connection { private isOpen: boolean = false; open(): void { this.isOpen = true; } close(): void { this.isOpen = false; } send(data: string): void { if (!this.isOpen) { throw new Error("Connection not open"); // VIOLATION } // send data }} // Client code can't trust that send() works without checking stateWhy these patterns emerge:
In each case, a developer faced a requirement that didn't fit the existing hierarchy:
The reflex to inherit and throw is natural—it reuses existing code with minimal apparent effort. But it creates a hierarchy that lies about its capabilities.
When you find yourself writing throw new UnsupportedOperationException() in an override, stop. This is a signal that inheritance is the wrong relationship. The subclass isn't truly a specialized version of the parent—it's a different thing that happens to share some code.
When subclasses throw unexpected exceptions, client code must defend against them. This creates a cascade of defensive patterns that spreads through the codebase, adding complexity and obscuring intent.
| Stage | What Happens | Code Impact |
|---|---|---|
| Initial Bug | Exception surfaces in production | Incident response, hotfix deployed |
| Direct Defense | Try-catch added around the call | Error handling logic bloats method |
| Propagation | Other callers add same defense | Defensive code appears in multiple locations |
| Abstraction Decay | Wrappers created to 'safe-ify' calls | Additional indirection layers added |
| Documentation Debt | Methods documented with 'may throw' warnings | Comments replace type contracts |
| Testing Burden | Tests must cover exception paths for each subtype | Test complexity explodes |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
// STAGE 1: Original clean codefunction processAllDocuments(docs: Document[]): void { for (const doc of docs) { doc.save(); // Trusts the abstraction }} // STAGE 2: After first production incident, direct defensefunction processAllDocuments(docs: Document[]): void { for (const doc of docs) { try { doc.save(); } catch (e) { console.error(`Failed to save document: ${e.message}`); // What now? Skip? Retry? Fail all? } }} // STAGE 3: Abstraction decay - "safe" wrappers emergefunction safelySave(doc: Document): boolean { try { doc.save(); return true; } catch (e) { return false; }} function processAllDocuments(docs: Document[]): SaveResult { const results = docs.map(doc => ({ doc, success: safelySave(doc) })); return new SaveResult(results);} // STAGE 4: Capability checking emergesfunction processAllDocuments(docs: Document[]): void { for (const doc of docs) { // Now we're type-checking - see previous lesson if (doc instanceof ReadOnlyDocument) { // Skip read-only documents continue; } doc.save(); }} // STAGE 5: Full defensive meta-layerinterface SaveCapable { canSave(): boolean; save(): void;} function processAllDocuments(docs: Document[]): void { for (const doc of docs) { if ('canSave' in doc && doc.canSave()) { doc.save(); } else { // Handle non-saveable documents } }} // The abstraction is now covered in defensive scar tissueThe cost analysis:
Each stage of the cascade adds overhead:
| Stage | Lines of Code | Complexity | Maintenance |
|---|---|---|---|
| Original | 4 | O(1) | Trivial |
| Direct Defense | 10 | O(n) per caller | Medium |
| Safe Wrappers | 20+ | O(n²) propagation | High |
| Capability Checks | 15+ | Type-check overhead | Very High |
| Meta-Layer | 30+ | New abstraction layer | Extreme |
A single exception-throwing override can multiply code complexity by 5-10x across the codebase.
The cascade doesn't happen immediately. First comes the incident. Then the hotfix. Then another incident in a different context. Slowly, defensive code spreads. By the time the pattern is recognized, it's entrenched in dozens of files. The fix becomes a major refactoring effort.
The LSP provides clear rules about exceptions in inheritance hierarchies. Understanding these rules helps you design contracts that subclasses can actually fulfill.
Exception, child can throw SpecificException, but not add new exception types12345678910111213141516171819202122232425262728293031323334353637383940414243
// RULE: Methods should declare their exception behavior // Parent establishes exception contract (via documentation in TypeScript)abstract class PaymentProcessor { /** * Process a payment. * @throws PaymentFailedException if the payment network is unavailable * @throws InvalidPaymentException if payment details are invalid * Does NOT throw for any other reason - clients can rely on this */ abstract process(payment: Payment): Receipt;} // VALID: Subclass narrows exceptions (throws fewer types)class LocalPaymentProcessor extends PaymentProcessor { // Never throws PaymentFailedException (no network needed) // May throw InvalidPaymentException process(payment: Payment): Receipt { if (!payment.isValid()) { throw new InvalidPaymentException("Invalid payment"); } return new Receipt(payment); }} // INVALID: Subclass adds new exception typeclass StrictPaymentProcessor extends PaymentProcessor { process(payment: Payment): Receipt { // Parent doesn't declare this exception type! if (payment.amount > this.limit) { throw new LimitExceededException("Over limit"); // VIOLATION } return new Receipt(payment); }} // INVALID: Subclass throws where parent never doesclass ReadOnlyPaymentProcessor extends PaymentProcessor { process(payment: Payment): Receipt { // Parent guarantees processing will occur throw new UnsupportedOperationException(); // VIOLATION }}Java's checked exception system enforces exception contracts at compile time—but only for checked exceptions. RuntimeException subclasses bypass this entirely. In dynamically typed languages or with unchecked exceptions, the contract is purely documentation-based. This is why LSP violations via exceptions often slip through until production.
Designing for exception honesty:
When defining a base class or interface, consider what exceptions are part of the contract:
If a subclass needs to throw an exception that isn't part of these categories—especially "I can't do this operation"—it's a sign that the inheritance relationship is wrong.
When you're tempted to write an exception-throwing override, stop and consider these design alternatives. Each addresses the underlying need without violating LSP.
Result<T, E> or Optional<T> instead of throwing1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
// PROBLEM: ReadOnlyDocument can't save but extends Document // ALTERNATIVE 1: Split the interfaceinterface Readable { getContent(): string;} interface Saveable { save(): void;} // Now types claim only what they can doclass EditableDocument implements Readable, Saveable { getContent(): string { return this.content; } save(): void { /* actually saves */ }} class ReadOnlyDocument implements Readable { getContent(): string { return this.content; } // No save() - doesn't claim capability it doesn't have} // Client code is honest about requirementsfunction exportDocument(doc: Readable): void { /* ... */ }function autosave(doc: Saveable): void { doc.save(); } // ALTERNATIVE 2: Capability hierarchyinterface Document { getContent(): string;} interface EditableDocument extends Document { save(): void; edit(content: string): void;} interface ImmutableDocument extends Document { // Explicitly no mutation methods readonly createdAt: Date;} // ALTERNATIVE 3: Result types instead of exceptionsinterface SaveResult { success: boolean; error?: string;} class Document { save(): SaveResult { // Default implementation return { success: true }; }} class ReadOnlyDocument extends Document { override save(): SaveResult { // Doesn't throw - returns failure result return { success: false, error: "Document is read-only" }; }} // ALTERNATIVE 4: Null Object patterninterface Document { save(): void;} class NullSaveDocument implements Document { save(): void { // Does nothing - safely // Optionally logs: "Save operation ignored for read-only document" }} // ALTERNATIVE 5: Composition over inheritanceclass DocumentViewer { private content: string; // No save() method at all - this class is for viewing getContent(): string { return this.content; } // If saving is needed, compose with a saver saveWith(saver: DocumentSaver): void { saver.save(this.content); }}Notice how Alternative 1 (Split the interface) connects to the Interface Segregation Principle (ISP). LSP violations via exceptions often indicate ISP violations—the base interface is too large, forcing subtypes to 'refuse' parts of it. If you're throwing exceptions in overrides, consider whether the interface should be smaller.
Preventing exception-based LSP violations requires both proactive design practices and detection mechanisms in your development process.
UnsupportedOperationException, NotImplementedError, etc. in method bodies12345678910111213141516171819
# Find common exception-throwing override patterns # TypeScript/JavaScriptgrep -rn "throw new.*Unsupported" --include="*.ts" src/grep -rn "throw new.*NotImplemented" --include="*.ts" src/grep -rn "throw new Error.*cannot.*" --include="*.ts" src/grep -rn "throw new Error.*not supported" --include="*.ts" src/ # Pythongrep -rn "raise NotImplementedError" --include="*.py" src/grep -rn "raise UnsupportedOperation" --include="*.py" src/grep -rn "raise.*cannot.*" --include="*.py" src/ # Javagrep -rn "throw new UnsupportedOperationException" --include="*.java" src/grep -rn "throw new NotImplementedException" --include="*.java" src/ # Find override methods that might be problematicgrep -rn "@Override" --include="*.java" -A5 src/ | grep -B5 "throw new"Contract testing pattern:
Write tests that exercise the base class contract with every concrete implementation:
123456789101112131415161718192021222324252627282930
// Contract test ensures all implementations fulfill the contractdescribe('Document contract', () => { // All concrete implementations to test const implementations: Document[] = [ new LocalDocument('/tmp/test.txt'), new CloudDocument('bucket/key'), new ReadOnlyDocument('content'), // This test will fail! new CachedDocument(new LocalDocument('/tmp/test.txt')), ]; implementations.forEach(doc => { describe(`${doc.constructor.name}`, () => { it('should save without throwing', () => { // This is the contract test expect(() => doc.save()).not.toThrow(); }); it('should return content after save', () => { doc.setContent('test'); doc.save(); expect(doc.getContent()).toBe('test'); }); // More contract tests... }); });}); // When ReadOnlyDocument is tested, the test fails// This surfaces the LSP violation before productionYou now understand how exception-throwing overrides violate LSP, the defensive cascade they create, exception contract rules, and design alternatives. The next page explores partial implementations—another form of LSP violation where subclasses provide incomplete behavior.
Next up:
The following page examines partial implementations—subclasses that don't fully implement their inherited interface, leaving some methods as stubs, no-ops, or returning default values that don't satisfy the contract's intent.