Liskov Substitution Principle
Intermediate · 11 min read · ▶ live playground · ✦ checkpoint
A banner tool accepts a Rectangle, sets its width to eight and height to four, then expects an area of 32. Someone passes a Square because a square is mathematically a rectangle; the same code reports 16.
The Liskov Substitution Principle (LSP) says a subtype must be usable anywhere its base type is expected without surprising the caller. A behavioral contract is the meaning and guarantees callers rely on, not only a matching list of method names.
Inheritance makes an is-a claim. LSP asks whether that claim survives actual method calls.
Before: the subtype changes the promise
The rectangle API lets width and height change independently. A square must keep both sides equal. The child can preserve its own rule only by changing behavior that rectangle callers already depend on.
class diagram
Before: Square cannot honor Rectangle resizing
- 1Rectangle promises that callers may change width and height independently.
- 2Square extends Rectangle but changes both dimensions whenever either setter runs.
- 3BannerLayout trusts the base contract: set width to 8, height to 4, then read area 32.
- 4Substituting Square produces area 16, so matching method signatures were not enough.
The code compiles because Square has every method a Rectangle has. Runtime behavior exposes the broken promise:
class Rectangle {
private width: number;
private height: number;
public constructor(width: number, height: number) {
this.width = width;
this.height = height;
}
public setWidth(value: number): void {
this.width = value;
}
public setHeight(value: number): void {
this.height = value;
}
public area(): number {
return this.width * this.height;
}
}
class Square extends Rectangle {
public constructor(size: number) {
super(size, size);
}
// smell: the subtype changes the base setters from independent to coupled.
public override setWidth(value: number): void {
super.setWidth(value);
super.setHeight(value);
}
public override setHeight(value: number): void {
super.setWidth(value);
super.setHeight(value);
}
}
class BannerLayout {
public resize(shape: Rectangle): number {
shape.setWidth(8);
shape.setHeight(4);
return shape.area();
}
}
const layout = new BannerLayout();
console.log(layout.resize(new Rectangle(2, 2)));
console.log(layout.resize(new Square(2)));32 16
BannerLayout uses only the base type, so it is entitled to the base behavior. After setWidth(8), a rectangle caller expects the height to stay unchanged. Square.setWidth() silently changes it. Then setHeight(4) changes the width again.
Two contract terms make the rule more precise:
- A precondition is a condition the caller must satisfy before an operation runs.
- A postcondition is a guarantee the operation provides after it succeeds.
A subtype must not demand stronger preconditions than its base. A base method that accepts any positive width cannot be replaced by one that accepts only widths equal to the current height. A subtype must also preserve the base postconditions. If setWidth() promises that only width changes, changing height breaks that guarantee.
After: expose only contracts each shape can keep
The fix is not a cleverer override. The model separates the shared ability to report area from rectangle-specific resizing. Square joins the broad Shape contract, while only a shape that supports independent width-and-height changes joins ResizableRectangle.
class diagram
After: shared area, honest resizing
- 1Shape promises only area(), an operation both concrete shapes can honor.
- 2ResizableRectangle adds the independent width-and-height operation to Shape.
- 3Rectangle implements resizing without weakening or changing its contract.
- 4Square remains substitutable as a Shape and makes no false resizing promise.
- 5BannerLayout now requests the narrower capability it truly needs.
The interface arrow from ResizableRectangle to Shape reads “a resizable rectangle is also a shape.” Square does not sit below that narrower contract, so TypeScript prevents the invalid substitution before the banner flow runs.
interface Shape {
area(): number;
}
interface ResizableRectangle extends Shape {
resizeTo(width: number, height: number): void;
}
class Rectangle implements ResizableRectangle {
private width: number;
private height: number;
public constructor(width: number, height: number) {
this.width = width;
this.height = height;
}
public resizeTo(width: number, height: number): void {
if (width <= 0 || height <= 0) {
throw new Error("Dimensions must be positive.");
}
this.width = width;
this.height = height;
}
public area(): number {
return this.width * this.height;
}
}
class Square implements Shape {
private side: number;
public constructor(side: number) {
this.side = side;
}
public area(): number {
return this.side * this.side;
}
}
class BannerLayout {
public resize(shape: ResizableRectangle): number {
shape.resizeTo(8, 4);
return shape.area();
}
}
function areaLabel(shape: Shape): string {
return `${shape.area()} square units`;
}
const layout = new BannerLayout();
console.log(layout.resize(new Rectangle(2, 2)));
console.log(areaLabel(new Square(5)));32 25 square units
Both concrete classes are safe substitutes for Shape: each returns its own area as a number. Substitution does not require identical results. It requires every result and side effect to stay within the meaning promised by the shared type.
When not to force an is-a relationship
LSP is not a principle to switch off. It is a test that tells you when a proposed subtype is dishonest. If the child must reject valid base calls, throw “not supported,” or reinterpret an inherited operation, stop forcing the is-a claim.
Split the contract when callers need different capabilities, as the shape refactor does. Prefer composition when the would-be child needs a reusable behavior but is not behaviorally the same kind of thing. A penguin can be a Bird, but it should not inherit a FlyingBird.fly() promise it cannot keep; flight can be a separate capability held by birds that support it.
Do not confuse LSP with banning specialized behavior. A subtype may calculate differently, return a more specific result where the type system allows it, or strengthen guarantees. It must still accept the base type's valid use and preserve everything the base promised.
Checkpoint
Answer all three to mark this lesson complete
You can now judge inheritance by the behavior a caller receives, not the geometry of a type tree. Next, Interface Segregation keeps those contracts narrow enough that every implementer can honor what it promises.