Loading content...
You've built a NonNegativeAccount class with a rock-solid invariant: the balance can never go negative. Every method maintains this property. Tests verify it. Production depends on it.
Then a developer creates OverdraftAccount as a subclass, adding a method that allows the balance to go negative (after all, that's what overdraft means). The type system is happy—OverdraftAccount inherits all methods and can be used anywhere a NonNegativeAccount is expected.
But now, every piece of code that trusts a NonNegativeAccount to have a non-negative balance is silently broken. Financial calculations produce negative numbers where they expect positive. Fee calculations divide by unexpected values. Reports display impossible states.
The subclass didn't just break its own invariant—it broke the parent's invariant, and in doing so, broke every piece of code that relies on that invariant through polymorphism.
This page explores the fundamental rule: subclasses must preserve parent invariants. We'll understand why this rule exists, how to uphold it, and what happens when it's violated.
By the end of this page, you will understand the LSP invariant preservation rule, learn how subclasses can strengthen (but never weaken) parent invariants, recognize common patterns that accidentally break parent invariants, and discover design techniques for safe invariant inheritance.
The Liskov Substitution Principle demands a precise rule about invariants in inheritance:
A subclass must preserve all invariants of its superclass. A subclass may add new invariants, but it cannot weaken or remove any invariant that the superclass guarantees.
This rule is non-negotiable. It's what makes polymorphism safe. Let's understand why:
The Substitution Argument:
This is the essence of LSP: anywhere a superclass is expected, a subclass must be safely usable. If subclasses could violate parent invariants, polymorphism would be fundamentally unsafe.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
// Parent class with documented invariantsclass PositiveNumber { protected value: number; // INVARIANT: value > 0 (always strictly positive) constructor(value: number) { if (value <= 0) { throw new Error("Value must be positive"); } this.value = value; } getValue(): number { return this.value; } multiply(factor: number): PositiveNumber { if (factor <= 0) { throw new Error("Factor must be positive to maintain invariant"); } return new PositiveNumber(this.value * factor); }} // CORRECT: Subclass that PRESERVES the invariantclass PositiveInteger extends PositiveNumber { // INVARIANT: value > 0 AND value is an integer // This STRENGTHENS the parent invariant (adds requirement, doesn't remove) constructor(value: number) { if (!Number.isInteger(value)) { throw new Error("Value must be an integer"); } super(value); // Parent constructor ensures value > 0 } // All inherited methods still work because: // - A positive integer is still a positive number // - The parent invariant is preserved} // INCORRECT: Subclass that VIOLATES the parent invariantclass ZeroableNumber extends PositiveNumber { // ATTEMPTING: value >= 0 (allowing zero) // This WEAKENS the parent invariant - LSP VIOLATION constructor(value: number) { if (value >= 0) { // Bypass parent validation by direct assignment super(1); // Satisfy parent, then override this.value = value; // VIOLATION: now value could be 0 } else { throw new Error("Value must be non-negative"); } } // Now methods inherited from parent can fail: // getValue() might return 0, surprising code expecting positive // Code dividing by getValue() could crash} // Client code trusts the invariantfunction calculateRatio(a: PositiveNumber, b: PositiveNumber): number { // Safe division - BOTH values are guaranteed positive by invariant return a.getValue() / b.getValue();} // With ZeroableNumber, this crashes or returns Infinityconst dangerous = new ZeroableNumber(0);const ratio = calculateRatio(new PositiveNumber(10), dangerous); // Division by zero!Notice how ZeroableNumber seems like a reasonable extension—after all, zero is 'close to' positive numbers. But relaxing an invariant is never safe. Any code that depended on the original strict invariant (like safe division) breaks silently. This is why LSP forbids weakening invariants.
Understanding the difference between strengthening and weakening invariants is crucial for LSP compliance.
Strengthening an invariant means adding additional constraints. The subclass promises EVERYTHING the parent promises, PLUS MORE. This is always safe because:
Weakening an invariant means removing or relaxing constraints. The subclass promises LESS than the parent. This is always dangerous because:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
// PARENT: Generic bank accountabstract class BankAccount { protected balance: number; // INVARIANT: balance >= -overdraftLimit // (allows some flexibility in the base class) constructor(initialBalance: number) { if (initialBalance < -this.getOverdraftLimit()) { throw new Error("Initial balance below overdraft limit"); } this.balance = initialBalance; } protected abstract getOverdraftLimit(): number; getBalance(): number { return this.balance; } withdraw(amount: number): void { if (amount <= 0) { throw new Error("Withdrawal amount must be positive"); } if (this.balance - amount < -this.getOverdraftLimit()) { throw new Error("Insufficient funds"); } this.balance -= amount; }} // STRENGTHENING: Savings account has NO overdraftclass SavingsAccount extends BankAccount { // INVARIANT: balance >= 0 (STRONGER than parent's balance >= -limit) // Subclass states are a SUBSET of parent states protected getOverdraftLimit(): number { return 0; // No overdraft allowed } // Inherited withdraw() now enforces stricter rule automatically} // STRENGTHENING: High-value account requires minimum balanceclass HighValueAccount extends BankAccount { private readonly minimumBalance: number = 10000; // INVARIANT: balance >= minimumBalance >= 0 // Even STRONGER than SavingsAccount protected getOverdraftLimit(): number { return -this.minimumBalance; // Effective floor at minimumBalance } constructor(initialBalance: number) { super(initialBalance); if (initialBalance < this.minimumBalance) { throw new Error(`Balance must be at least ${this.minimumBalance}`); } }} // This client code works with ANY BankAccountfunction transferFunds(from: BankAccount, to: BankAccount, amount: number): void { // Works with SavingsAccount, HighValueAccount, any future account type // Because they all PRESERVE (or strengthen) the parent invariant from.withdraw(amount); // If we get here, from.balance is still >= -overdraftLimit to.deposit(amount);}The Subset Rule:
Think of invariants as defining the set of valid object states:
For LSP compliance: S must be a subset of P (S ⊆ P)
Every state the subclass can be in must also be a valid state for the parent. This ensures that any code expecting a parent type will receive an object in a state it can handle.
Imagine valid states as points in a space. The parent invariant draws a circle around valid states. Strengthening makes the circle smaller (fewer states, but all still inside parent's circle). Weakening makes the circle larger (states exist that are outside parent's circle). Only strengthening preserves substitutability.
Understanding how invariants get broken helps you avoid these pitfalls. Here are the most common ways subclasses accidentally violate parent invariants:
1. Adding Methods That Bypass Parent Constraints
123456789101112131415161718192021222324
class ProtectedList<T> { protected items: T[] = []; protected readonly maxSize: number = 100; // INVARIANT: items.length <= maxSize add(item: T): void { if (this.items.length >= this.maxSize) { throw new Error("List is full"); } this.items.push(item); }} class UnsafeExtendedList<T> extends ProtectedList<T> { // VIOLATION: New method bypasses size check addMany(newItems: T[]): void { // Directly pushes without checking maxSize this.items.push(...newItems); // Can exceed maxSize! } // Now items.length could be 1000 when maxSize is 100 // Parent invariant is violated}2. Overriding Methods Without Preserving Invariant Checks
123456789101112131415161718192021222324252627282930313233343536373839
class ValidatedString { protected value: string; // INVARIANT: value is never empty and contains no control characters constructor(value: string) { this.setValue(value); } protected setValue(value: string): void { if (!value || value.length === 0) { throw new Error("Value cannot be empty"); } if (/[\x00-\x1F]/.test(value)) { throw new Error("Value cannot contain control characters"); } this.value = value; } getValue(): string { return this.value; }} class UnsafeString extends ValidatedString { // VIOLATION: Override removes validation protected setValue(value: string): void { // "Simplified" version that skips checks this.value = value; // Empty strings now allowed! } // Invariant broken: value could be "" or contain control characters} // Client trusts the invariantfunction displayMessage(msg: ValidatedString): void { // Assumes msg.getValue() is safe, non-empty, no control chars console.log(`Message: ${msg.getValue()}`); // Could print nothing or garbage}3. Exposing State Modification That Parent Forbids
12345678910111213141516171819202122232425262728293031323334353637383940
class ImmutableConfig { protected readonly settings: Map<string, string>; // INVARIANT: settings never change after construction constructor(initial: Map<string, string>) { this.settings = new Map(initial); // Defensive copy } get(key: string): string | undefined { return this.settings.get(key); } // No setter - immutability is the invariant} class MutableConfig extends ImmutableConfig { // VIOLATION: Adds mutation capability set(key: string, value: string): void { // TypeScript allows this because readonly only prevents reassignment (this.settings as Map<string, string>).set(key, value); } // Invariant broken: settings now change after construction} // Client caches result because of immutability invariantclass ConfigCache { private cached: Map<ImmutableConfig, string[]> = new Map(); getKeys(config: ImmutableConfig): string[] { if (!this.cached.has(config)) { // Safe to cache because config is "immutable" this.cached.set(config, [...config.getKeys()]); } return this.cached.get(config)!; }} // With MutableConfig, cache becomes stale and returns wrong data4. Changing Constructor Behavior
12345678910111213141516171819202122232425262728293031323334353637383940
class BoundedCounter { protected count: number; protected readonly min: number; protected readonly max: number; // INVARIANT: min <= count <= max constructor(min: number, max: number, initial: number) { if (min > max) { throw new Error("min must not exceed max"); } if (initial < min || initial > max) { throw new Error("initial must be between min and max"); } this.min = min; this.max = max; this.count = initial; } increment(): void { if (this.count < this.max) { this.count++; } }} class UnsafeCounter extends BoundedCounter { // VIOLATION: Constructor accepts invalid state constructor() { // Force superclass to accept, then override super(0, 100, 50); // Now set invalid values (this as any).min = 100; (this as any).max = 0; // min > max! (this as any).count = 500; // Outside any valid range! } // Object exists in impossible state according to parent invariant}Notice how most violations involve protected fields. When you expose fields to subclasses, you're trusting those subclasses to maintain invariants. This trust is often misplaced. Prefer private fields with carefully designed protected methods for safe extension.
Given the many ways subclasses can break invariants, how do we design parent classes that resist violation? Here are proven strategies:
1. Make Fields Private, Not Protected
Protected fields are invitations to invariant violations. Subclasses can modify them directly, bypassing any checks.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// UNSAFE: Protected field can be violated by subclassclass UnsafeBase { protected balance: number; // INVARIANT: balance >= 0 constructor(balance: number) { if (balance < 0) throw new Error("Negative balance"); this.balance = balance; }} class DangerousSubclass extends UnsafeBase { breakInvariant(): void { this.balance = -1000; // Direct access violates invariant }} // SAFE: Private field with controlled accessorclass SafeBase { private _balance: number; // INVARIANT: balance >= 0 constructor(balance: number) { if (balance < 0) throw new Error("Negative balance"); this._balance = balance; } get balance(): number { return this._balance; } // Protected method for safe modification protected setBalance(newBalance: number): void { if (newBalance < 0) { throw new Error("Cannot set negative balance"); } this._balance = newBalance; }} class SafeSubclass extends SafeBase { customWithdraw(amount: number): void { // Must use protected setter - invariant enforced this.setBalance(this.balance - amount); // Throws if result negative } tryToBreak(): void { // this._balance = -1000; // ERROR: Private, not accessible }}2. Use Final/Sealed Methods for Critical Operations
Prevent subclasses from overriding methods that maintain invariants.
123456789101112131415161718192021222324252627282930313233343536
// Note: TypeScript doesn't have native 'final' for methods,// but you can use patterns to achieve similar effect class SecureAccount { private balance: number; // INVARIANT: balance >= 0 AND all changes are logged constructor(initial: number) { this.balance = this.validateAndSet(initial); } // This method should NEVER be overridden - it ensures invariant private validateAndSet(amount: number): number { if (amount < 0) { throw new Error("Amount cannot be negative"); } this.logChange(this.balance, amount); // Audit trail return amount; } // Template method: subclasses customize this, not the validation protected logChange(oldValue: number, newValue: number): void { console.log(`Balance changed: ${oldValue} -> ${newValue}`); } withdraw(amount: number): void { const newBalance = this.balance - amount; this.balance = this.validateAndSet(newBalance); } // Subclasses can add behavior, but can't bypass validation} // In Java, you would use 'final' keyword:// final void validateAndSet(double amount) { ... }3. Favor Composition Over Inheritance for Extension
If you're worried about subclass violations, don't use inheritance at all.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
// INSTEAD OF inheritance, use compositionclass NonNegativeBalance { private balance: number; // INVARIANT: balance >= 0 constructor(initial: number) { if (initial < 0) throw new Error("Negative balance"); this.balance = initial; } getBalance(): number { return this.balance; } adjust(amount: number): void { const newBalance = this.balance + amount; if (newBalance < 0) { throw new Error("Would result in negative balance"); } this.balance = newBalance; }} // Extension through composition - cannot break NonNegativeBalance invariantclass AccountWithHistory { private balance: NonNegativeBalance; // HAS-A, not IS-A private history: number[] = []; constructor(initial: number) { this.balance = new NonNegativeBalance(initial); this.history.push(initial); } getBalance(): number { return this.balance.getBalance(); } adjust(amount: number): void { this.balance.adjust(amount); // Delegates - invariant protected this.history.push(this.balance.getBalance()); } getHistory(): number[] { return [...this.history]; } // CANNOT break NonNegativeBalance's invariant - no access to private field}When you compose objects instead of inheriting, the contained object's invariants are automatically protected. AccountWithHistory has no way to set NonNegativeBalance's internal balance to -100. The private field is completely inaccessible. This is the safest way to build on existing classes while preserving their guarantees.
4. Design Hierarchies with Invariants in Mind
When you know a class will be inherited, design the invariants to be preserved naturally:
| Pattern | How It Helps | Example |
|---|---|---|
| Template Method | Fixed algorithm with customizable steps; invariant-critical code isn't overridable | Validation in final method, logging in overridable method |
| Factory Methods | Control object creation to ensure valid initial state | Static create() method that validates before constructing |
| Immutable Objects | No state changes after construction; invariants can't be broken | Value objects with final fields and no setters |
| Defensive Copying | Prevent external mutation of internal state | Return new collections instead of internal references |
| Interface-Based Design | Define contracts without exposing implementation details | Program to interfaces, hide invariant-bearing implementation |
How do you verify that subclasses correctly preserve parent invariants? Testing is your primary defense.
1. Write Parent-Level Test Suites That Subclasses Must Pass
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
// Abstract test suite that all BankAccount subclasses must passabstract class BankAccountTestSuite { protected abstract createAccount(initial: number): BankAccount; // Tests that verify invariant preservation testBalanceNeverNegative(): void { const account = this.createAccount(100); // After withdraw, balance should still be >= 0 or throw try { account.withdraw(50); assert(account.getBalance() >= 0, "Balance must be non-negative after withdraw"); } catch (e) { // If exception thrown, balance unchanged assert(account.getBalance() === 100, "Balance unchanged on failed withdraw"); } // Attempting to overdraw should throw or be rejected try { account.withdraw(1000); // If no exception, balance must still be non-negative assert(account.getBalance() >= 0, "Balance must be non-negative after large withdraw"); } catch (e) { // Expected - withdrawal rejected } } testMultipleOperationsPreserveInvariant(): void { const account = this.createAccount(500); // Random sequence of operations const operations = [ () => account.deposit(100), () => account.withdraw(50), () => account.deposit(200), () => account.withdraw(300), () => account.withdraw(100), ]; for (const op of operations) { try { op(); } catch (e) { // Operation failed, which is acceptable } // After EVERY operation, invariant must hold assert(account.getBalance() >= 0, "Invariant violated!"); } } testConstructorEnforcesInvariant(): void { // Should throw for negative initial balance assertThrows(() => this.createAccount(-100)); }} // Concrete test for SavingsAccountclass SavingsAccountTests extends BankAccountTestSuite { protected createAccount(initial: number): BankAccount { return new SavingsAccount(initial); }} // Concrete test for CheckingAccountclass CheckingAccountTests extends BankAccountTestSuite { protected createAccount(initial: number): BankAccount { return new CheckingAccount(initial); }} // Run the same tests for all subclassesnew SavingsAccountTests().runAll();new CheckingAccountTests().runAll();// Any new BankAccount subclass should also run these tests2. Property-Based Testing for Invariants
Property-based testing generates random inputs and verifies that invariants hold:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// Using a property-based testing library like fast-checkimport * as fc from 'fast-check'; describe('SortedList invariant preservation', () => { // Invariant: Items are always sorted it('maintains sort invariant after any sequence of operations', () => { fc.assert( fc.property( fc.array(fc.integer()), // Random array of integers (numbers) => { const list = new SortedList<number>((a, b) => a - b); // Add all numbers for (const n of numbers) { list.add(n); // INVARIANT CHECK: Must be sorted after every add const items = list.toArray(); for (let i = 1; i < items.length; i++) { if (items[i-1] > items[i]) { return false; // Invariant violated! } } } return true; // Invariant preserved } ) ); }); it('min() always returns smallest element', () => { fc.assert( fc.property( fc.array(fc.integer(), { minLength: 1 }), (numbers) => { const list = new SortedList<number>((a, b) => a - b); numbers.forEach(n => list.add(n)); // Property: min() returns actual minimum return list.min() === Math.min(...numbers); } ) ); });});Consider adding invariant checks in your actual code (not just tests) during development. Use assertion libraries or simple if-throw checks. While you might disable them in production for performance, they catch violations early during development and testing.
Let's consolidate what we've learned about preserving invariants in subclasses:
What's Next:
Now that you understand how invariants should be preserved, the next page explores what happens when they're not: Invariant Violations. We'll examine real-world scenarios where invariant violations cause system failures, learn to diagnose invariant-related bugs, and study the cascading effects of broken invariants through polymorphic code.
Understanding violations isn't just about avoiding them—it's about recognizing them in existing systems and understanding why they're so dangerous. This knowledge will make you a better debugger and a more cautious designer.
You now understand the critical responsibility of subclasses to preserve parent invariants. This isn't just a best practice—it's a fundamental requirement for polymorphism to work safely. When subclasses maintain parent invariants, code can trust type hierarchies. When they don't, polymorphism becomes a source of subtle, dangerous bugs.