Loading content...
We've explored preconditions, postconditions, and the logic of weakening and strengthening. Now we unite these concepts with class invariants to form the complete behavioral contract that governs LSP compliance.
This page presents the unified contract rules — the formal conditions that, when satisfied, guarantee a subclass can safely substitute for its parent in any context. We'll see how preconditions, postconditions, and invariants work together, explore practical verification techniques, and examine real-world designs that get this right (and wrong).
By the end, you'll have a comprehensive mental model for designing and evaluating inheritance hierarchies that truly satisfy the Liskov Substitution Principle.
By the end of this page, you will: • Understand class invariants as the third pillar of behavioral contracts • Master the complete set of LSP contract rules • Know how to verify LSP compliance systematically • Recognize the interplay between preconditions, postconditions, and invariants • Apply these principles to design robust inheritance hierarchies
A class invariant is a logical assertion that must be true for every object of that class at all "observable" moments — specifically, at the end of construction and at the entry and exit of every public method.
While preconditions and postconditions describe individual method behavior, invariants describe object integrity — conditions that define what it means for an object to be in a valid, consistent state.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
/** * A bank account with explicit invariants. * * INVARIANTS: * - balance >= 0 (no overdrafts allowed) * - balance == sum of all transaction amounts * - transactions array is never null * - accountId is immutable after construction */class BankAccount { private readonly accountId: string; private balance: number; private transactions: Transaction[] = []; constructor(accountId: string, initialDeposit: number) { if (initialDeposit < 0) { throw new Error("Initial deposit must be non-negative"); } this.accountId = accountId; this.balance = initialDeposit; this.transactions.push({ type: 'deposit', amount: initialDeposit }); // INVARIANT ESTABLISHED: balance >= 0, matches transactions } /** * @precondition amount > 0 * @precondition amount <= this.balance * @postcondition balance decreased by amount * @MAINTAINS INVARIANT: balance >= 0 */ withdraw(amount: number): void { // Precondition enforcement if (amount <= 0 || amount > this.balance) { throw new Error("Invalid withdrawal amount"); } this.balance -= amount; this.transactions.push({ type: 'withdrawal', amount: -amount }); // INVARIANT MAINTAINED: balance still >= 0 } /** * @precondition amount > 0 * @postcondition balance increased by amount * @MAINTAINS INVARIANT: balance >= 0, transactions consistent */ deposit(amount: number): void { if (amount <= 0) { throw new Error("Invalid deposit amount"); } this.balance += amount; this.transactions.push({ type: 'deposit', amount }); // INVARIANT MAINTAINED } // Invariant check (for debugging/testing) private checkInvariant(): void { if (this.balance < 0) { throw new Error("INVARIANT VIOLATED: negative balance"); } const expectedBalance = this.transactions .reduce((sum, t) => sum + t.amount, 0); if (this.balance !== expectedBalance) { throw new Error("INVARIANT VIOLATED: balance mismatch"); } }}In this example, the invariants are:
balance >= 0: The account never has a negative balancebalance == sum(transactions): The balance is always the sum of all recorded transactionstransactions != null: The transaction list always existsaccountId is immutable: The ID never changes after constructionEvery public method must preserve these invariants. If any method could leave the object with balance < 0, it would violate the invariant.
Think of invariants as universal postconditions — conditions that every method promises to maintain, without having to repeat them for each method.
Postcondition: "After withdraw(), balance is decreased by amount" Invariant: "After ANY method, balance >= 0"
The invariant applies automatically to all methods; the postcondition is specific to one.
How must subclasses handle parent invariants? The rule is the same as for postconditions:
A subclass must preserve all parent class invariants and may add stronger ones.
This makes intuitive sense: if BankAccount guarantees balance >= 0, then every subclass must also guarantee this. Otherwise, code that relies on the invariant breaks when given a subclass instance.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
// ═══════════════════════════════════════════════════════════════// PARENT CLASS WITH INVARIANT// ═══════════════════════════════════════════════════════════════ class SortedList<T> { protected items: T[] = []; /** * INVARIANT: this.items is always sorted in ascending order */ add(item: T): void { // Insert at correct position to maintain sortedness const index = this.findInsertPosition(item); this.items.splice(index, 0, item); // INVARIANT MAINTAINED } getAll(): T[] { return [...this.items]; // Returns sorted array } getFirst(): T | undefined { return this.items[0]; // Smallest element } protected findInsertPosition(item: T): number { // Binary search for correct position let low = 0, high = this.items.length; while (low < high) { const mid = Math.floor((low + high) / 2); if (this.items[mid] < item) low = mid + 1; else high = mid; } return low; }} // ═══════════════════════════════════════════════════════════════// ❌ LSP VIOLATION: Breaks parent's invariant// ═══════════════════════════════════════════════════════════════ class FastList<T> extends SortedList<T> { /** * "Optimized" add that appends without sorting. * * ⚠️ BREAKS INVARIANT: items may not be sorted! */ add(item: T): void { this.items.push(item); // Just append - fast but unsorted! // ❌ INVARIANT VIOLATED: items are no longer sorted } // A "lazy" approach to sort only when needed sortIfNeeded(): void { this.items.sort((a, b) => (a as any) - (b as any)); }} // ═══════════════════════════════════════════════════════════════// CLIENT CODE RELYING ON INVARIANT// ═══════════════════════════════════════════════════════════════ function findMedian(list: SortedList<number>): number { const all = list.getAll(); // CLIENT RELIES ON: all is sorted (invariant of SortedList) return all[Math.floor(all.length / 2)]; // Middle element IS median} // With SortedList: Returns actual median ✅// With FastList: Returns random middle element! ❌ function findSmallest(list: SortedList<number>): number | undefined { // CLIENT RELIES ON: list is sorted, so first element is smallest return list.getFirst();} // With SortedList: Returns smallest ✅ // With FastList: Returns whatever was added first! ❌Unlike precondition violations that often throw immediately, invariant violations may go unnoticed for a long time. The client code calls getFirst() expecting the smallest element but gets garbage. No exception is thrown — just wrong results.
This is why invariants are critical to document and verify. They're the silent contracts that code implicitly relies on.
The correct approach — Strengthening Invariants:
123456789101112131415161718192021222324252627282930313233343536
// ═══════════════════════════════════════════════════════════════// ✅ LSP COMPLIANT: Strengthens parent's invariant// ═══════════════════════════════════════════════════════════════ class UniqueSortedList<T> extends SortedList<T> { /** * INHERITS INVARIANT: items is always sorted ascending * ADDS INVARIANT: items contains no duplicates (STRONGER!) */ add(item: T): void { const index = this.findInsertPosition(item); // Check if item already exists at this position if (this.items[index] === item) { return; // Skip duplicate } this.items.splice(index, 0, item); // BOTH INVARIANTS MAINTAINED: sorted AND unique } contains(item: T): boolean { // Can use O(log n) binary search since items are sorted const index = this.findInsertPosition(item); return this.items[index] === item; }} // CLIENT ANALYSIS://// Code written for SortedList expects: sorted items// UniqueSortedList provides: sorted AND unique items//// Sorted + unique ⟹ sorted (stronger implies original)// ✅ All clients work correctly, with bonus uniqueness guaranteeWe can now state the complete set of rules that define LSP compliance in terms of behavioral contracts. A subclass S is a valid substitute for parent T if and only if all four conditions are satisfied:
| Rule | Requirement | Direction | Intuition |
|---|---|---|---|
| Precondition Rule | P_parent → P_child | May weaken only | Accept at least what parent accepts |
| Postcondition Rule | Q_child → Q_parent | May strengthen only | Deliver at least what parent promises |
| Invariant Rule | I_child → I_parent | May strengthen only | Maintain at least parent's guarantees |
| History Rule | Child's modifications are parent-compatible | May not add restrictions | Evolution follows parent's rules |
Let's examine each rule in detail:
P_parent → P_child
For each method, if the parent's precondition is satisfied, the child's precondition must also be satisfied. The child may weaken (accept more inputs) but never strengthen (reject parent-valid inputs).
1234567
// Parent: @precondition amount > 0// Valid child preconditions:// @precondition amount >= 0 (weaker - also accepts 0) ✅// @precondition true (weaker - accepts anything) ✅// Invalid child preconditions:// @precondition amount > 10 (stronger - rejects 1-10) ❌// @precondition amount > 0 && isWeekday() (stronger) ❌LSP compliance requires satisfying ALL four rules. A subclass that perfectly handles preconditions and postconditions but breaks an invariant is still non-substitutable. Similarly, respecting all static contracts but surprising clients with unexpected state transitions (history constraint) breaks the principle.
Let's work through a comprehensive example that applies all four rules to verify LSP compliance:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
// ═══════════════════════════════════════════════════════════════// PARENT CLASS: Generic Cache// ═══════════════════════════════════════════════════════════════ abstract class Cache<K, V> { protected entries: Map<K, V> = new Map(); /** * INVARIANTS: * - entries is never null * - all keys in entries are non-null * - size() == entries.size (accurate count) */ /** * Stores a value in the cache. * * @precondition key != null && value != null * @postcondition get(key) == value * @postcondition size() >= old(size()) (size doesn't decrease) */ abstract put(key: K, value: V): void; /** * Retrieves a value from the cache. * * @precondition key != null * @postcondition if contained: returns value, else returns null */ abstract get(key: K): V | null; /** * @postcondition returns accurate count of entries */ size(): number { return this.entries.size; }} // ═══════════════════════════════════════════════════════════════// CANDIDATE SUBCLASS: LRU Cache with size limit// ═══════════════════════════════════════════════════════════════ class LRUCache<K, V> extends Cache<K, V> { private maxSize: number; private accessOrder: K[] = []; /** * INHERITS INVARIANTS: * - entries is never null ✅ * - all keys non-null ✅ * - size() == entries.size ✅ * * ADDS INVARIANTS: * - entries.size <= maxSize (additional constraint) ✅ * - accessOrder contains exactly the keys in entries ✅ */ constructor(maxSize: number) { super(); this.maxSize = maxSize; } /** * @precondition key != null && value != null (SAME as parent ✅) * @postcondition get(key) == value (SAME ✅) * @postcondition size() >= old(size()) - WAIT, is this true? */ put(key: K, value: V): void { // If at capacity and new key, must evict oldest if (this.entries.size >= this.maxSize && !this.entries.has(key)) { const oldest = this.accessOrder.shift()!; this.entries.delete(oldest); } this.entries.set(key, value); this.updateAccessOrder(key); } /** * @precondition key != null (SAME as parent ✅) * @postcondition if contained: returns value, else returns null (SAME ✅) * @postcondition updates access order (STRONGER - extra side effect ✅) */ get(key: K): V | null { const value = this.entries.get(key); if (value !== undefined) { this.updateAccessOrder(key); return value; } return null; } private updateAccessOrder(key: K): void { const index = this.accessOrder.indexOf(key); if (index > -1) { this.accessOrder.splice(index, 1); } this.accessOrder.push(key); }} // ═══════════════════════════════════════════════════════════════// LSP COMPLIANCE ANALYSIS// ═══════════════════════════════════════════════════════════════ /** * PRECONDITION ANALYSIS: * * put(): Parent requires key != null && value != null * Child requires the same * P_parent → P_child? YES ✅ * * get(): Parent requires key != null * Child requires the same * P_parent → P_child? YES ✅ */ /** * POSTCONDITION ANALYSIS: * * put(): Parent promises get(key) == value AND size >= old(size) * Child delivers get(key) == value ✅ * BUT: size may DECREASE if eviction happens! * * If cache is full and we add a new key: * - We evict one entry (size stays same or decreases) * - This VIOLATES "size >= old(size)"! * * Q_child → Q_parent? NO! ❌ * * get(): Parent promises returns value if present, null otherwise * Child does the same * Q_child → Q_parent? YES ✅ */ /** * INVARIANT ANALYSIS: * * Parent invariants: * - entries never null: Child maintains ✅ * - keys non-null: Child maintains ✅ * - size() accurate: Child maintains ✅ * * Child adds stricter invariant: size <= maxSize * This is strengthening, which is allowed ✅ * * I_child → I_parent? YES ✅ */ /** * HISTORY ANALYSIS: * * Parent makes no explicit temporal promises about entries. * But implicitly, put() adds entries, suggesting they persist. * * Child evicts entries silently — a key that was present may * become absent without explicit removal. This COULD surprise * clients expecting cache to be durable. * * History constraint: POTENTIALLY VIOLATED ⚠️ * Depends on whether clients expect entries to persist. */ /** * VERDICT: LRUCache VIOLATES LSP * * Primary violation: put() postcondition "size >= old(size)" * Secondary concern: History constraint for entry persistence */This is a realistic example of how LSP violations sneak in. The LRUCache seems reasonable — it's a perfectly valid cache implementation. But it cannot substitute for the parent Cache because it breaks the "size never decreases on put" promise.
The fix isn't to change LRUCache — it's to recognize that Cache and LRUCache have different contracts, so inheritance may be the wrong relationship.
How to fix this design?
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
// ═══════════════════════════════════════════════════════════════// OPTION 1: Weaken parent's postcondition// ═══════════════════════════════════════════════════════════════ abstract class Cache<K, V> { /** * @postcondition get(key) == value * @postcondition size() may change (no size guarantee!) * ^^^^ Weakened to allow LRU behavior */ abstract put(key: K, value: V): void;} // Now LRUCache is compliant — but all clients must handle size changes // ═══════════════════════════════════════════════════════════════// OPTION 2: Separate interfaces for different cache types// ═══════════════════════════════════════════════════════════════ interface Cache<K, V> { /** @postcondition get(key) returns value or null */ put(key: K, value: V): void; get(key: K): V | null;} interface BoundedCache<K, V> extends Cache<K, V> { /** @postcondition may evict on put */ getMaxSize(): number;} interface UnboundedCache<K, V> extends Cache<K, V> { /** @postcondition never evicts (size only grows) */} // Now clients can choose based on their needs:// - Need guaranteed persistence? Use UnboundedCache// - Accept eviction? Use BoundedCache or just Cache // ═══════════════════════════════════════════════════════════════// OPTION 3: Use composition instead of inheritance// ═══════════════════════════════════════════════════════════════ class LRUCache<K, V> { // NOT extending Cache private storage = new Map<K, V>(); private maxSize: number; // ... LRU implementation} // LRUCache has its own contract, no LSP concernsHow do you verify LSP compliance in practice? Here are systematic approaches:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
// ═══════════════════════════════════════════════════════════════// SUBSTITUTION TEST PATTERN// ═══════════════════════════════════════════════════════════════ describe('SortedList LSP Compliance', () => { // Test against parent contract, run with each implementation const implementations = [ () => new SortedList<number>(), () => new UniqueSortedList<number>(), () => new ReverseSortedList<number>(), // Might fail! ]; implementations.forEach((createList, index) => { describe(`Implementation ${index}`, () => { let list: SortedList<number>; beforeEach(() => { list = createList(); }); // Test postconditions it('maintains sorted order after add', () => { list.add(5); list.add(2); list.add(8); const items = list.getAll(); for (let i = 1; i < items.length; i++) { expect(items[i]).toBeGreaterThanOrEqual(items[i-1]); } }); // Test invariant it('getFirst returns minimum element', () => { list.add(5); list.add(2); list.add(8); expect(list.getFirst()).toBe(2); // Smallest }); // If any implementation fails these tests, // it violates LSP! }); });}); // ═══════════════════════════════════════════════════════════════// CONTRACT ASSERTIONS (Runtime Checking)// ═══════════════════════════════════════════════════════════════ function withContractChecks<T>( implementation: SortedList<T>): SortedList<T> { return new Proxy(implementation, { get(target, prop) { if (prop === 'add') { return (item: T) => { const sizeBefore = target.getAll().length; target.add(item); const sizeAfter = target.getAll().length; // Check postcondition: size increased console.assert( sizeAfter >= sizeBefore, 'Postcondition violated: size decreased' ); // Check invariant: still sorted const items = target.getAll(); for (let i = 1; i < items.length; i++) { console.assert( items[i] >= items[i-1], 'Invariant violated: not sorted' ); } }; } return (target as any)[prop]; } });}Based on the contract rules, here are practical guidelines for designing hierarchies that naturally satisfy LSP:
Ask yourself: "If someone wrote correct code using only the parent type's documentation (never looking at any subclass), would their code work with ANY subclass?"
If yes, your hierarchy is LSP-compliant. If you can think of a subclass that would break their code, you have an LSP violation.
Let's examine the most common LSP violations and strategies to prevent them:
| Pitfall | Example | Solution |
|---|---|---|
| Throwing where parent returns | Parent returns null, child throws exception | Maintain return semantics; use separate exception-throwing method if needed |
| Adding validation in overrides | Child rejects inputs parent accepts | Move validation to parent or use composition for restricted variant |
| Weakening output guarantees | Parent returns non-null, child can return null | Maintain or strengthen output guarantees |
| Breaking invariants for optimization | Child uses lazy init that invalidates invariant temporarily | Ensure invariants hold at all public method boundaries |
| Adding mutable state that affects behavior | Child has flag that changes inherited method behavior | Ensure mutations don't break substitutability |
| Violating history constraints | Child allows undoing what parent considers permanent | Respect parent's state transition contracts |
12345678910111213141516171819202122232425262728293031323334353637383940414243
// ═══════════════════════════════════════════════════════════════// PITFALL: Adding validation in overrides// ═══════════════════════════════════════════════════════════════ // ❌ WRONG: Child rejects valid parent inputsclass Validator { validate(input: string): boolean { return input.length > 0; // Accepts any non-empty string }} class StrictValidator extends Validator { validate(input: string): boolean { if (!input.match(/^[a-zA-Z]+$/)) { throw new Error("Only letters allowed"); // ❌ Rejects valid inputs! } return super.validate(input); }} // ✅ RIGHT: Use composition for a different validation strategyclass StrictValidator { // NOT extending Validator validate(input: string): boolean { return input.length > 0 && /^[a-zA-Z]+$/.test(input); }} // Or: Make the parent more abstractinterface Validator { validate(input: string): boolean; // Contract allows any strategy} class AnyNonEmptyValidator implements Validator { validate(input: string): boolean { return input.length > 0; }} class LettersOnlyValidator implements Validator { validate(input: string): boolean { return input.length > 0 && /^[a-zA-Z]+$/.test(input); }} // Both implement Validator, no inheritance, no LSP issuesModule Complete!
You've now mastered the contract-based understanding of LSP. You can:
This knowledge forms the foundation for robust, maintainable object-oriented design. In the next module, we'll explore Invariants in Inheritance in even greater depth, examining complex scenarios and advanced patterns for maintaining correctness across class hierarchies.
You now possess a rigorous, formal understanding of LSP through the lens of Design by Contract. This isn't just theoretical knowledge — it's a practical framework for analyzing and designing real systems that truly support substitutability.