Loading learning content...
You've found the LSP violations in your codebase. You understand why they're problems—the instanceof checks, the exception-throwing overrides, the partial implementations. Now comes the hard part: fixing them without breaking the system.
Unlike greenfield development where you can design correctly from the start, refactoring LSP violations in production code means:
This page provides battle-tested strategies for this exact scenario—systematic approaches to transforming LSP-violating code into properly substitutable hierarchies while keeping the system running.
By the end of this page, you will have a toolkit of refactoring strategies for LSP violations: when to use each, how to execute them safely, and how to verify that the refactored design actually satisfies LSP. You'll learn to sequence changes for minimal disruption and maximum confidence.
Before diving into specific techniques, establish the right mental framework for LSP refactoring. This mindset prevents common mistakes that trade one LSP violation for another.
The fundamental question:
For each LSP-violating subclass, ask: "Is this truly a behavioral specialization of the parent, or is it a different thing entirely?"
If it's truly a specialization, fix it by completing its implementation or narrowing the parent's contract.
If it's a different thing, extract it from the hierarchy entirely.
| Situation | Likely Solution | Key Indicator |
|---|---|---|
| Subclass can't do something parent can | Extract to different interface | Methods throw UnsupportedOperationException |
| Subclass does same thing differently | Template Method or Strategy | Same contract, different implementation |
| Subclass adds new capabilities | Separate interface for new capabilities | New methods only some subclasses have |
| Subclass has additional preconditions | Weaken parent's preconditions or separate type | Validation that throws in subclass only |
| Hierarchy models taxonomy, not behavior | Flatten to composition | IS-A relationship doesn't imply substitutability |
Large LSP refactorings often benefit from the Strangler Fig approach: build the correct structure alongside the broken one, gradually migrate clients, then remove the old hierarchy. This avoids big-bang changes that risk breaking production.
The most common fix for LSP violations is recognizing that the subclass shouldn't be a subclass at all. It's a different type that happens to share some code or structure with the parent, but can't substitute for it.
When to use this strategy:
The extraction process:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
// BEFORE: ReadOnlyDocument extends Document but can't save class Document { private content: string = ''; getContent(): string { return this.content; } setContent(content: string): void { this.content = content; } save(path: string): void { fs.writeFileSync(path, this.content); }} class ReadOnlyDocument extends Document { override setContent(content: string): void { throw new Error("Cannot modify read-only document"); } override save(path: string): void { throw new Error("Cannot save read-only document"); }} // STEP 1 & 2: Identify shared behavior, create new interfaceinterface Readable { getContent(): string;} // STEP 3: Both types implement shared interface independentlyclass Document implements Readable { private content: string = ''; getContent(): string { return this.content; } setContent(content: string): void { this.content = content; } save(path: string): void { fs.writeFileSync(path, this.content); }} class ReadOnlyDocument implements Readable { constructor(private readonly content: string) {} getContent(): string { return this.content; } // No setContent, no save - doesn't claim capabilities it lacks} // STEP 4: Update clients to use appropriate types// Old code: function preview(doc: Document) - forced to accept ReadOnlyDocument// New code:function preview(doc: Readable): void { const content = doc.getContent(); displayPreview(content);} // STEP 5: Clients needing full capabilities use specific typefunction editAndSave(doc: Document, newContent: string): void { doc.setContent(newContent); doc.save('/tmp/output.txt');}// This now cannot accept ReadOnlyDocument - type system prevents error // TYPE HIERARCHY AFTER REFACTORING://// Readable (interface)// ↑ ↑// Document ReadOnlyDocument//// No inheritance between Document and ReadOnlyDocumentManaging the migration:
In a large codebase, you can't update all clients at once. Use these transitional techniques:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// TECHNIQUE 1: Deprecation with forwarding// Keep old hierarchy temporarily, mark as deprecated /** * @deprecated Use Readable interface instead. * Document and ReadOnlyDocument no longer share a hierarchy. */abstract class LegacyDocument implements Readable { abstract getContent(): string;} class Document extends LegacyDocument { /* ... */ }class ReadOnlyDocument extends LegacyDocument { /* ... */ } // Old client code continues to work but gets deprecation warnings// New code uses Readable directly // TECHNIQUE 2: Parallel type hierarchies// Create new structure, map between old and new // Old structure (problematic)namespace Legacy { export class Document { /* ... */ } export class ReadOnlyDocument extends Document { /* ... */ }} // New structure (correct)namespace Clean { export interface Readable { /* ... */ } export class EditableDocument implements Readable { /* ... */ } export class ImmutableDocument implements Readable { /* ... */ }} // Adapter during migrationfunction toLegacy(doc: Clean.Readable): Legacy.Document { if (doc instanceof Clean.EditableDocument) { return new Legacy.Document(doc.getContent()); } return new Legacy.ReadOnlyDocument(doc.getContent());} function fromLegacy(doc: Legacy.Document): Clean.Readable { if (doc instanceof Legacy.ReadOnlyDocument) { return new Clean.ImmutableDocument(doc.getContent()); } return new Clean.EditableDocument(doc.getContent());} // Gradually migrate code from Legacy to Clean namespaceWhen extracting, ensure the new shared interface is meaningful. An interface with only getContent() might be too thin to be useful. Consider whether clients actually need to work with both types polymorphically, or if they should just use the specific type.
Often LSP violations occur because an interface is too large—it includes capabilities that not all implementations can provide. The solution is to split the interface along capability boundaries.
When to use this strategy:
The segregation is performed in conjunction with the Interface Segregation Principle (ISP):
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
// BEFORE: Fat interface with LSP violations interface DataStore { get(key: string): Promise<any>; set(key: string, value: any): Promise<void>; delete(key: string): Promise<void>; list(): Promise<string[]>; watch(key: string, callback: (value: any) => void): void; transaction<T>(fn: (tx: Transaction) => T): Promise<T>;} // MemoryStore can't watch or do transactionsclass MemoryStore implements DataStore { async get(key: string) { /* works */ } async set(key: string, value: any) { /* works */ } async delete(key: string) { /* works */ } async list() { /* works */ } watch(key: string, callback: (value: any) => void) { // VIOLATION: Does nothing silently } async transaction<T>(fn: (tx: Transaction) => T): Promise<T> { // VIOLATION: No real transaction support return fn(null as Transaction); }} // AFTER: Segregated interfaces // Core capability - all stores have thisinterface KeyValueStore { get(key: string): Promise<any>; set(key: string, value: any): Promise<void>; delete(key: string): Promise<void>;} // Optional capabilities - only some stores have theseinterface ListableStore extends KeyValueStore { list(prefix?: string): Promise<string[]>;} interface WatchableStore { watch(key: string, callback: (value: any) => void): Subscription;} interface TransactionalStore<T = any> { transaction<R>(fn: (tx: Transaction) => R): Promise<R>;} // Implementations declare exactly what they supportclass MemoryStore implements KeyValueStore, ListableStore { async get(key: string) { /* works */ } async set(key: string, value: any) { /* works */ } async delete(key: string) { /* works */ } async list(prefix?: string) { /* works */ } // No watch or transaction - doesn't claim to have them} class RedisStore implements KeyValueStore, ListableStore, WatchableStore { async get(key: string) { /* works */ } async set(key: string, value: any) { /* works */ } async delete(key: string) { /* works */ } async list(prefix?: string) { /* works */ } watch(key: string, callback: (value: any) => void) { /* works */ } // No transaction - Redis doesn't support it} class PostgresStore implements KeyValueStore, ListableStore, TransactionalStore { async get(key: string) { /* works */ } async set(key: string, value: any) { /* works */ } async delete(key: string) { /* works */ } async list(prefix?: string) { /* works */ } async transaction<R>(fn: (tx: Transaction) => R) { /* works */ } // No watch - Postgres doesn't support it (without LISTEN/NOTIFY)} // Clients request exactly the capability they needfunction cacheValue(store: KeyValueStore, key: string, value: any): Promise<void> { return store.set(key, value);} function watchForChanges(store: WatchableStore, key: string): Subscription { return store.watch(key, (value) => console.log('Changed:', value));} function updateWithLock( store: KeyValueStore & TransactionalStore, key: string, updater: (current: any) => any): Promise<void> { return store.transaction(async (tx) => { const current = await store.get(key); const updated = updater(current); await store.set(key, updated); });} // Only PostgresStore satisfies KeyValueStore & TransactionalStore// MemoryStore and RedisStore correctly cannot be used for transaction operationsTypeScript's intersection types (A & B) are powerful for segregated interfaces. A function can require exactly the capabilities it needs: KeyValueStore & WatchableStore means 'I need both'. This is more precise than inheritance and avoids forcing implementations to provide everything.
Many LSP violations exist because inheritance was used for code reuse when composition would have been more appropriate. Converting from inheritance to composition eliminates the substitutability question entirely—composed objects don't claim to be substitutes for their components.
When to use this strategy:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
// BEFORE: Inheritance for code reuse, causes LSP violation class Rectangle { constructor(protected width: number, protected height: number) {} setWidth(w: number): void { this.width = w; } setHeight(h: number): void { this.height = h; } getArea(): number { return this.width * this.height; }} class Square extends Rectangle { constructor(side: number) { super(side, side); } // Must maintain the invariant: width === height override setWidth(w: number): void { this.width = w; this.height = w; // VIOLATION: Changes both! } override setHeight(h: number): void { this.width = h; // VIOLATION: Changes both! this.height = h; }} // Client code that breaks with Square:function doubleWidth(rect: Rectangle): void { const oldHeight = rect.getArea() / rect.getWidth(); rect.setWidth(rect.getWidth() * 2); // Expected: area doubles // With Square: area quadruples because height also changed!} // AFTER: Composition - extract shared concepts // Shared concept: something with dimensionsinterface Dimensions { width: number; height: number;} // Shared concept: calculating area from dimensionsfunction calculateArea(dims: Dimensions): number { return dims.width * dims.height;} // Rectangle: mutable, width and height independentclass Rectangle { private dims: Dimensions; constructor(width: number, height: number) { this.dims = { width, height }; } setWidth(w: number): void { this.dims.width = w; } setHeight(h: number): void { this.dims.height = h; } getArea(): number { return calculateArea(this.dims); }} // Square: enforces constraint, uses side not width/heightclass Square { constructor(private side: number) {} setSide(s: number): void { this.side = s; } getSide(): number { return this.side; } getArea(): number { return calculateArea({ width: this.side, height: this.side }); } // Note: No setWidth/setHeight - that's not how squares work} // If you need polymorphic area calculation:interface HasArea { getArea(): number;} class Rectangle implements HasArea { /* ... */ }class Square implements HasArea { /* ... */ }class Circle implements HasArea { constructor(private radius: number) {} getArea(): number { return Math.PI * this.radius ** 2; }} // Now substitutable - all HasArea objects can report their areafunction totalArea(shapes: HasArea[]): number { return shapes.reduce((sum, shape) => sum + shape.getArea(), 0);}The composition transformation pattern:
If you need an object to 'act like' another object in some contexts, use delegation: the composed object forwards calls to its component. This gives you the flexibility of composition with the convenience of polymorphism through a shared interface.
Sometimes the hierarchy is correct, but the contract is wrong. Either the parent promises too much (and subclasses can't deliver) or too little (and variations are expected). Adjusting the contract rather than restructuring the hierarchy can resolve LSP violations.
Contract weakening (expanding what's allowed):
Contract strengthening (restricting what's required):
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
// EXAMPLE 1: Weakening the return type contract // BEFORE: Contract promises non-null resultabstract class Repository<T> { abstract findById(id: string): T; // Implies always returns value} class UserRepository extends Repository<User> { findById(id: string): User { // What if user doesn't exist? Must throw or return fake user const user = this.db.find(id); if (!user) throw new NotFoundError(id); // Caller might not expect this return user; }} class CachedUserRepository extends Repository<User> { findById(id: string): User { const cached = this.cache.get(id); if (cached) return cached; // What if not in cache? Fall back to DB? Return fake? // The contract doesn't allow null! }} // AFTER: Weaken contract to allow null (option type)abstract class Repository<T> { abstract findById(id: string): T | null; // Explicitly may not find} class UserRepository extends Repository<User> { findById(id: string): User | null { return this.db.find(id); // Can naturally return null }} class CachedUserRepository extends Repository<User> { findById(id: string): User | null { const cached = this.cache.get(id); if (cached) return cached; return this.inner.findById(id); // May also return null }} // Clients updated to handle nullconst user = repository.findById('123');if (user === null) { console.log('User not found');} else { console.log(user.name);} // EXAMPLE 2: Weakening precondition requirements // BEFORE: Method has hidden preconditionabstract class NumberProcessor { // Documentation says: "processes positive numbers" // But some subclasses enforce, others don't abstract process(n: number): number;} class DoubleProcessor extends NumberProcessor { process(n: number): number { return n * 2; // Works for any number }} class SquareRootProcessor extends NumberProcessor { process(n: number): number { if (n < 0) throw new Error("Cannot process negative"); // VIOLATION return Math.sqrt(n); }} // AFTER: Make precondition explicit and consistentabstract class NumberProcessor { // Contract clearly states: accepts any finite number // Subclasses must handle all cases abstract process(n: number): number;} class SquareRootProcessor extends NumberProcessor { process(n: number): number { // Handles negative by returning NaN, consistent with Math.sqrt return Math.sqrt(n); }} // OR: Create separate interface for restricted inputsinterface PositiveNumberProcessor { /** * @param n A positive number (n > 0) * @throws if n <= 0 */ process(n: number): number;} // Now caller knows this requires positive inputfunction processSafely(processor: PositiveNumberProcessor, n: number): number { if (n <= 0) { throw new Error("Invalid input for PositiveNumberProcessor"); } return processor.process(n);}| Aspect | Parent Can→Child May | Rule |
|---|---|---|
| Return type | T → T or subtype of T | Covariance: child can return more specific type |
| Parameter type | T → T or supertype of T | Contravariance: child can accept broader input |
| Exceptions | Set E → Subset of E | Child can throw fewer exception types, not more |
| Preconditions | P → Weaker than P | Child can require less, not more |
| Postconditions | Q → Stronger than Q | Child can promise more, not less |
Weakening a contract (allowing null where non-null was promised, for example) is a breaking change for existing clients. Plan for client updates when adjusting contracts. Strengthening is usually safe—clients get more than they expected.
When subclasses need to vary only specific parts of an algorithm while maintaining the same overall contract, the Template Method pattern provides LSP-safe variation. The parent controls the structure; subclasses only vary well-defined extension points.
When to use this strategy:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
// BEFORE: Each subclass reimplements entire process// Risk: subclasses might skip important steps, violating LSP abstract class DataExporter { abstract export(data: Data): void;} class CsvExporter extends DataExporter { export(data: Data): void { // Validate if (!this.isValid(data)) throw new Error(); // Transform const csv = this.toCsv(data); // Log logger.info('Exporting to CSV'); // Write fs.writeFileSync('output.csv', csv); }} class JsonExporter extends DataExporter { export(data: Data): void { // Forgot validation! LSP violation const json = JSON.stringify(data); // Forgot logging! Inconsistent behavior fs.writeFileSync('output.json', json); }} // AFTER: Template Method ensures consistent behaviorabstract class DataExporter { // Template method - controls the algorithm export(data: Data): void { // These steps are guaranteed for ALL exporters this.validate(data); this.logStart(data); const formatted = this.format(data); // Subclass varies this this.write(formatted); // Subclass varies this this.logComplete(data); } // Fixed steps - subclasses cannot skip private validate(data: Data): void { if (!data || !data.isValid()) { throw new ValidationError('Invalid data'); } } private logStart(data: Data): void { logger.info(`Starting export of ${data.id}`); } private logComplete(data: Data): void { logger.info(`Completed export of ${data.id}`); } // Extension points - subclasses implement these protected abstract format(data: Data): string; protected abstract write(content: string): void;} class CsvExporter extends DataExporter { protected format(data: Data): string { // Only worry about CSV formatting return data.rows.map(row => row.join(',')).join('\n'); } protected write(content: string): void { fs.writeFileSync('output.csv', content); }} class JsonExporter extends DataExporter { protected format(data: Data): string { return JSON.stringify(data); } protected write(content: string): void { fs.writeFileSync('output.json', content); }} // Now ALL exporters:// - Validate before processing// - Log start and completion// - Handle the same invariants//// Substitutability guaranteed by design!Template methods can offer two types of extension points: abstract methods (subclass must implement) and hooks (optional overrides with default behavior). Use abstract methods for essential variation; hooks for optional customization. This keeps required contract fulfillment explicit.
Refactoring LSP violations is only complete when you can verify that the new design actually satisfies LSP. This requires specific testing approaches that go beyond standard unit testing.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
// CONTRACT TEST SUITE// Runs the same tests against all implementations describe('KeyValueStore Contract', () => { // All implementations to verify const implementations: [string, () => KeyValueStore][] = [ ['MemoryStore', () => new MemoryStore()], ['RedisStore', () => new RedisStore(testRedisConfig)], ['PostgresStore', () => new PostgresStore(testDbConfig)], ]; implementations.forEach(([name, factory]) => { describe(name, () => { let store: KeyValueStore; beforeEach(() => { store = factory(); }); // Contract: get returns undefined for missing key it('should return undefined for missing key', async () => { const result = await store.get('nonexistent'); expect(result).toBeUndefined(); }); // Contract: get returns value after set it('should return value after set', async () => { await store.set('key', { value: 42 }); const result = await store.get('key'); expect(result).toEqual({ value: 42 }); }); // Contract: delete removes value it('should remove value after delete', async () => { await store.set('key', 'value'); await store.delete('key'); const result = await store.get('key'); expect(result).toBeUndefined(); }); // Contract: overwrite works it('should overwrite existing value', async () => { await store.set('key', 'first'); await store.set('key', 'second'); const result = await store.get('key'); expect(result).toBe('second'); }); // Many more contract tests... }); });}); // SUBSTITUTION TEST// Verify that swapping implementations doesn't break dependent code describe('UserService with different stores', () => { const stores: KeyValueStore[] = [ new MemoryStore(), new RedisStore(testConfig), ]; stores.forEach(store => { describe(`with ${store.constructor.name}`, () => { let userService: UserService; beforeEach(() => { // Same UserService, different store implementation userService = new UserService(store); }); it('should create and retrieve user', async () => { const user = await userService.createUser({ name: 'Test' }); const retrieved = await userService.getUser(user.id); expect(retrieved).toEqual(user); }); // All user service tests run against all store implementations // Any failure indicates LSP violation }); });}); // PROPERTY-BASED TEST// Generate random operations, verify consistent behavior import * as fc from 'fast-check'; describe('KeyValueStore Properties', () => { const stores = [new MemoryStore(), new RedisStore(testConfig)]; stores.forEach(store => { describe(store.constructor.name, () => { it('get after set always retrieves value', () => { fc.assert(fc.asyncProperty( fc.string(), // random key fc.jsonValue, // random value async (key, value) => { await store.set(key, value); const result = await store.get(key); return deepEquals(result, value); } )); }); it('delete is idempotent', () => { fc.assert(fc.asyncProperty( fc.string(), async (key) => { await store.delete(key); await store.delete(key); // Second delete should not throw return true; } )); }); }); });});You now have a comprehensive toolkit for fixing LSP violations: extraction for non-substitutable types, interface segregation for capability variance, composition for code reuse without inheritance, contract adjustment for mismatched specifications, and template method for controlled variation. Use these strategies systematically, verify with contract tests, and your hierarchies will support true polymorphism.
Module complete:
You've now mastered the detection and remediation of LSP violations. You can recognize the symptoms (type checks, exceptions, partial implementations), understand their costs, and apply systematic refactoring strategies to restore proper substitutability. This knowledge enables you to design hierarchies where polymorphism actually works—where any subtype truly substitutes for its parent.
LSP compliance isn't achieved once and forgotten. Every new subclass is a potential violation. Build LSP checking into your code review process, your CI pipeline, and your design review discussions. The investment in maintaining substitutability pays dividends in system stability and evolvability.