Loading learning content...
When you use a library function, you're entering into an implicit agreement. The function's signature tells you what types to pass and what type you'll receive. But there's much more to the agreement than syntax:
sort(array) returns a sorted array, not a random permutationsave(entity) persists the entity, not deletes itvalidate(input) returns true for valid inputs, not for invalid onesThese are behavioral contracts—promises about what a function or method does, not just what types it accepts and returns. The Liskov Substitution Principle is fundamentally about preserving these contracts in inheritance hierarchies.
By the end of this page, you will understand how to think about inheritance in terms of behavioral contracts. You'll learn to identify the implicit promises your types make, ensure subclasses honor those promises, and understand why contract violations are so dangerous.
A behavioral contract is the complete specification of how a piece of code behaves—not just its interface, but its semantics. It encompasses:
1. Preconditions — What must be true before calling a method 2. Postconditions — What will be true after the method returns 3. Invariants — What is always true about an object 4. Exception behavior — When and what exceptions are thrown 5. Side effects — What external state changes occur
The concept of Design by Contract (DbC) was formalized by Bertrand Meyer in the Eiffel programming language, but the idea is universal. Every method has implicit contracts, whether or not they're documented.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
/** * BankAccount demonstrates explicit behavioral contracts */class BankAccount { private _balance: number; private _isOpen: boolean; // INVARIANT: balance >= 0 when account is open // INVARIANT: no operations allowed on closed accounts constructor(initialDeposit: number) { // PRECONDITION: initialDeposit >= 0 if (initialDeposit < 0) { throw new Error("Initial deposit cannot be negative"); } this._balance = initialDeposit; this._isOpen = true; } /** * Deposits money into the account * * PRECONDITION: amount > 0 * PRECONDITION: account is open * POSTCONDITION: balance increases by exactly 'amount' * POSTCONDITION: returns the new balance */ deposit(amount: number): number { if (!this._isOpen) { throw new Error("Cannot deposit to closed account"); } if (amount <= 0) { throw new Error("Deposit amount must be positive"); } const previousBalance = this._balance; this._balance += amount; // Postcondition check (would be enforced automatically in DbC languages) console.assert(this._balance === previousBalance + amount); return this._balance; } /** * Withdraws money from the account * * PRECONDITION: amount > 0 * PRECONDITION: amount <= balance * PRECONDITION: account is open * POSTCONDITION: balance decreases by exactly 'amount' * POSTCONDITION: balance >= 0 (maintains invariant) * POSTCONDITION: returns the withdrawn amount */ withdraw(amount: number): number { if (!this._isOpen) { throw new Error("Cannot withdraw from closed account"); } if (amount <= 0) { throw new Error("Withdrawal amount must be positive"); } if (amount > this._balance) { throw new Error("Insufficient funds"); } this._balance -= amount; // Invariant check console.assert(this._balance >= 0); return amount; } /** * Returns current balance * * PRECONDITION: none (can be called anytime) * POSTCONDITION: returns current balance value * POSTCONDITION: does not modify any state (query method) */ get balance(): number { return this._balance; } /** * Closes the account * * PRECONDITION: account is open * PRECONDITION: balance is 0 * POSTCONDITION: account is closed * POSTCONDITION: no further deposits/withdrawals possible */ close(): void { if (!this._isOpen) { throw new Error("Account already closed"); } if (this._balance > 0) { throw new Error("Cannot close account with positive balance"); } this._isOpen = false; } get isOpen(): boolean { return this._isOpen; }}Notice how the BankAccount class is not just a container for data—it's a specification of behavior. The comments document contracts that client code relies on, whether explicitly or not.
The LSP connection:
When you create a subclass of BankAccount—say, SavingsAccount or CheckingAccount—you must honor every single contract defined above. If SavingsAccount rejects positive deposits or allows negative balances, code written against BankAccount will break.
In practice, most codebases don't have explicit contract documentation like the BankAccount example. Contracts live in programmers' heads, in test assertions, in behavior that's "obvious" but never stated.
This makes LSP violations particularly dangerous—you're breaking promises that were never written down.
toString() returns a string — Never null, never throws (in most languages)equals(other) is symmetric — If a.equals(b), then b.equals(a)compareTo() is transitive — If a < b and b < c, then a < cclone() returns a copy — Modifications to the clone don't affect the originalsize() matches iteration count — Iterating produces exactly size() elementsWhen a subclass violates these implicit contracts, the results are particularly confusing because the contract was never stated. Developers debug for hours, not realizing that the subclass changed behavior they assumed was universal.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// Implicit contract: equals() is symmetric and consistent interface Entity { id: string; equals(other: Entity): boolean;} class User implements Entity { constructor(public id: string, public email: string) {} equals(other: Entity): boolean { if (other instanceof User) { return this.id === other.id; } return false; }} // Violating implicit symmetry contractclass SpecialUser extends User { constructor(id: string, email: string, public tier: 'gold' | 'silver') { super(id, email); } equals(other: Entity): boolean { if (other instanceof SpecialUser) { // Checks tier in addition to id return this.id === other.id && this.tier === other.tier; } // Falls back to User comparison return super.equals(other); }} // The symmetry violation in action:const user = new User("123", "user@example.com");const special = new SpecialUser("123", "special@example.com", "gold"); console.log(user.equals(special)); // true - User only checks idconsole.log(special.equals(user)); // false - user is not SpecialUser! // This breaks EVERY algorithm that assumes equals() is symmetric:// - Set membership checks// - HashMap key comparisons// - Sorting stability// - Duplicate detection // Example: Adding to a Set gives inconsistent behaviorconst entitySet = new Set<Entity>();entitySet.add(user);console.log([...entitySet].some(e => e.equals(special))); // trueconsole.log([...entitySet].some(e => special.equals(e))); // false!The equals() symmetry violation is particularly insidious because both implementations look reasonable in isolation. But together, they break fundamental assumptions that collections and algorithms depend on. This is why contracts need to be understood holistically.
When a class inherits from another, it inherits not just code but contracts. The rules for how contracts can be modified in subclasses are strict and asymmetric:
| Contract Type | Can Weaken? | Can Strengthen? | Rationale |
|---|---|---|---|
| Preconditions | ✅ Yes | ❌ No | Accepting more inputs is safe; rejecting previously-valid inputs breaks callers |
| Postconditions | ❌ No | ✅ Yes | Promising more is safe; promising less breaks callers who relied on guarantees |
| Invariants | ❌ No | ✅ Yes | Adding constraints is safe; removing constraints breaks assumptions |
| Exception specs | ✅ Can throw fewer | ❌ Can't throw more | Not throwing is safe; new exceptions crash unprepared callers |
Let's see each rule in practice:
Precondition weakening (allowed):
1234567891011121314151617181920212223242526272829303132333435
// Parent has strict preconditionclass PositiveCalculator { /** * PRECONDITION: value > 0 */ calculate(value: number): number { if (value <= 0) { throw new Error("Value must be positive"); } return Math.sqrt(value); }} // Child weakens precondition (accepts more values) - ALLOWEDclass LenientCalculator extends PositiveCalculator { /** * PRECONDITION: value can be any number (weaker!) */ calculate(value: number): number { if (value < 0) { return 0; // Handle negative gracefully } return Math.sqrt(value); }} // Safe: any caller of PositiveCalculator will work with LenientCalculatorfunction process(calc: PositiveCalculator): void { // Caller always passes positive values (honors parent's precondition) const result = calc.calculate(5); console.log(result);} process(new PositiveCalculator()); // Worksprocess(new LenientCalculator()); // Also works!Postcondition strengthening (allowed):
12345678910111213141516171819202122232425262728293031323334
// Parent promises result in rangeclass RangeGenerator { /** * POSTCONDITION: returns value between 0 and 100 */ generate(): number { return Math.random() * 100; }} // Child strengthens postcondition (narrower range) - ALLOWEDclass NarrowRangeGenerator extends RangeGenerator { /** * POSTCONDITION: returns value between 25 and 75 (stronger!) */ generate(): number { return 25 + Math.random() * 50; }} // Safe: any caller expecting 0-100 will be satisfied by 25-75function useGenerator(gen: RangeGenerator): void { const value = gen.generate(); // Caller expects value in 0-100 range if (value < 0 || value > 100) { throw new Error("Out of expected range!"); } console.log(`Value: ${value}`);} useGenerator(new RangeGenerator()); // WorksuseGenerator(new NarrowRangeGenerator()); // Also works - values always in rangeAsk yourself: "If I give a caller a child instance when they expect a parent, will they notice?" For correct contract inheritance, the answer should be "No, unless they benefit from stronger guarantees." The child can exceed expectations, never disappoint them.
Exceptions are a crucial part of behavioral contracts, but they're often overlooked in LSP discussions. The rules are:
IOException, the child can guarantee it never throwsSecurityException, the child must not throw it eitherException, the child can throw FileNotFoundException (a subtype)123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
// Exception contract examples abstract class DataLoader { /** * Loads data from source * @throws DataError if data cannot be loaded * @throws never throws ValidationError (implicit contract!) */ abstract load(source: string): string;} // VALID: Throws same or fewer exceptionsclass ReliableLoader extends DataLoader { load(source: string): string { // Never throws - stronger guarantee than parent try { return `Data from ${source}`; } catch { return ""; // Swallows all errors } }} // VALID: Throws more specific exceptionclass FileLoader extends DataLoader { load(source: string): string { // FileNotFoundError is a specific type of DataError if (!source.endsWith(".txt")) { throw new FileNotFoundError(`Cannot find: ${source}`); } return `Contents of ${source}`; }} // INVALID: Throws unexpected exception typeclass StrictLoader extends DataLoader { load(source: string): string { if (source.length < 5) { // VIOLATION: Parent contract says nothing about ValidationError throw new ValidationError("Source name too short"); } return `Data from ${source}`; }} // Client code written against DataLoaderfunction processData(loader: DataLoader, source: string): string { try { return loader.load(source); } catch (e) { if (e instanceof DataError) { // Expected - handle gracefully return "Default data"; } // Any other error propagates up // But wait! StrictLoader throws ValidationError, which isn't DataError // This unhandled exception crashes the program! throw e; }} // Works with ReliableLoader and FileLoader// CRASHES with StrictLoader when source.length < 5Java's checked exceptions partially enforce LSP at compile time—you cannot add new checked exception types in overriding methods. Languages like TypeScript, Python, and JavaScript don't have this safety net, making exception contract discipline a programmer responsibility.
One of the most practical ways to verify LSP compliance is through testing. If you have tests for the parent class, they should pass for every subclass. This is sometimes called contract testing or Liskov testing.
The Principle:
Every test written for a parent class should pass when run against any child class.
If a test fails, the child violates the parent's contract in some way.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
// Contract tests verify LSP compliance import { describe, it, expect } from 'vitest'; // Abstract test suite for any Collection implementationfunction collectionContractTests( createCollection: <T>() => Collection<T>, name: string) { describe(`${name} fulfills Collection contract`, () => { // Postcondition: empty collection has size 0 it('starts with size 0', () => { const collection = createCollection<number>(); expect(collection.size()).toBe(0); }); // Postcondition: size increases by 1 after add it('increases size after add', () => { const collection = createCollection<number>(); expect(collection.add(42)).toBe(true); expect(collection.size()).toBe(1); }); // Postcondition: contains returns true after add it('contains added element', () => { const collection = createCollection<string>(); collection.add("hello"); expect(collection.contains("hello")).toBe(true); }); // Postcondition: remove decreases size it('decreases size after remove', () => { const collection = createCollection<number>(); collection.add(1); collection.add(2); collection.remove(1); expect(collection.size()).toBe(1); }); // Invariant: size is never negative it('never has negative size', () => { const collection = createCollection<number>(); collection.remove(999); // Remove non-existent expect(collection.size()).toBeGreaterThanOrEqual(0); }); // Postcondition: clear makes collection empty it('is empty after clear', () => { const collection = createCollection<number>(); collection.add(1); collection.add(2); collection.clear(); expect(collection.size()).toBe(0); }); });} // Run contract tests for every implementationcollectionContractTests(() => new ArrayList(), 'ArrayList');collectionContractTests(() => new LinkedList(), 'LinkedList');collectionContractTests(() => new HashSet(), 'HashSet');collectionContractTests(() => new TreeSet(), 'TreeSet'); // If ANY test fails for a specific implementation,// that implementation violates the Collection contract!For even stronger verification, use property-based testing (like fast-check or Hypothesis). Instead of testing specific cases, you define properties that must hold for all inputs: "For all elements x, after add(x), contains(x) is true." These tools generate thousands of test cases automatically.
Implicit contracts are dangers waiting to happen. The more explicit you make your contracts, the less chance of LSP violations. Here are strategies for contract documentation:
123456789101112131415161718192021222324252627282930313233343536373839
// Using types to encode contracts // Instead of documenting "amount must be positive"// Encode it in the type systemtype PositiveNumber = number & { readonly __brand: 'positive' }; function asPositive(n: number): PositiveNumber { if (n <= 0) { throw new Error(`Expected positive number, got ${n}`); } return n as PositiveNumber;} // Now the type FORCES correct usageclass BankAccount { withdraw(amount: PositiveNumber): void { // No need to check - the type guarantees it's positive this._balance -= amount; }} // Compile-time enforcement:const account = new BankAccount();// account.withdraw(-50); // TYPE ERROR - can't pass raw numberaccount.withdraw(asPositive(50)); // OK - validated // Using assertion functions (TypeScript 3.7+)function assertPositive(value: number): asserts value is PositiveNumber { if (value <= 0) { throw new Error(`Expected positive number, got ${value}`); }} // The type narrows after assertionfunction process(amount: number): void { assertPositive(amount); // TypeScript now knows 'amount' is PositiveNumber account.withdraw(amount); // OK}Behavioral contracts are the invisible glue that makes type hierarchies work. LSP ensures that this glue holds fast across inheritance. Let's consolidate our understanding:
What's next:
We've established the theoretical and contractual foundations of LSP. The final page in this module explores why LSP matters for polymorphism—how LSP violations undermine the power of object-oriented programming and how LSP compliance enables clean, extensible architectures.
You now understand LSP as a mechanism for preserving behavioral contracts across inheritance hierarchies. Contracts—the full behavioral specification of methods—must be honored by subclasses for polymorphism to work correctly. Next, we'll see why LSP is essential for effective polymorphism.