5.1 KiB
id, title, category, priority, tags, related
| id | title | category | priority | tags | related | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| solid-lsp-contracts | SOLID - Liskov Substitution (Contracts) | solid-principles | critical |
|
|
Liskov Substitution Principle - Contracts
Subtypes must be substitutable for their base types without altering the correctness of the program. Derived classes must honor the contracts established by their base classes.
Bad Example
// Anti-pattern: Subclass violates the contract of the base class
class Rectangle {
protected _width: number;
protected _height: number;
constructor(width: number, height: number) {
this._width = width;
this._height = height;
}
get width(): number {
return this._width;
}
set width(value: number) {
this._width = value;
}
get height(): number {
return this._height;
}
set height(value: number) {
this._height = value;
}
getArea(): number {
return this._width * this._height;
}
}
// Square violates LSP - it changes the behavior of setters
class Square extends Rectangle {
constructor(side: number) {
super(side, side);
}
// Violates LSP: setter has different behavior than parent
set width(value: number) {
this._width = value;
this._height = value; // Unexpected side effect!
}
set height(value: number) {
this._width = value; // Unexpected side effect!
this._height = value;
}
}
// This code works with Rectangle but breaks with Square
function resizeRectangle(rect: Rectangle): void {
rect.width = 10;
rect.height = 5;
// Expects area to be 50, but Square gives 25!
console.assert(rect.getArea() === 50, 'Area should be 50');
}
const rectangle = new Rectangle(4, 4);
resizeRectangle(rectangle); // Works: area is 50
const square = new Square(4);
resizeRectangle(square); // Fails: area is 25, not 50
Good Example
// Correct approach: Use composition and proper abstractions
// Define what shapes can do
interface Shape {
getArea(): number;
getPerimeter(): number;
}
// Define what resizable shapes can do
interface ResizableShape extends Shape {
scale(factor: number): void;
}
// Immutable rectangle - no setters that could be violated
class Rectangle implements Shape {
constructor(
readonly width: number,
readonly height: number
) {
if (width <= 0 || height <= 0) {
throw new Error('Dimensions must be positive');
}
}
getArea(): number {
return this.width * this.height;
}
getPerimeter(): number {
return 2 * (this.width + this.height);
}
// Return new instance instead of mutating
withWidth(width: number): Rectangle {
return new Rectangle(width, this.height);
}
withHeight(height: number): Rectangle {
return new Rectangle(this.width, height);
}
scale(factor: number): Rectangle {
return new Rectangle(this.width * factor, this.height * factor);
}
}
// Square is its own shape, not a subtype of Rectangle
class Square implements Shape {
constructor(readonly side: number) {
if (side <= 0) {
throw new Error('Side must be positive');
}
}
getArea(): number {
return this.side * this.side;
}
getPerimeter(): number {
return 4 * this.side;
}
withSide(side: number): Square {
return new Square(side);
}
scale(factor: number): Square {
return new Square(this.side * factor);
}
}
// Functions work with the Shape interface
function printShapeInfo(shape: Shape): void {
console.log(`Area: ${shape.getArea()}`);
console.log(`Perimeter: ${shape.getPerimeter()}`);
}
// This works correctly with both Rectangle and Square
const rect = new Rectangle(10, 5);
printShapeInfo(rect); // Area: 50, Perimeter: 30
const square = new Square(5);
printShapeInfo(square); // Area: 25, Perimeter: 20
// For operations specific to rectangles, use Rectangle type
function createBanner(width: number, height: number): Rectangle {
return new Rectangle(width, height);
}
// For operations that work with any shape, use Shape interface
function calculateTotalArea(shapes: Shape[]): number {
return shapes.reduce((total, shape) => total + shape.getArea(), 0);
}
const shapes: Shape[] = [
new Rectangle(10, 5),
new Square(4),
new Rectangle(3, 7)
];
console.log(calculateTotalArea(shapes)); // 50 + 16 + 21 = 87
Why
-
Predictable Behavior: Code using the base type works correctly with any subtype. No surprises.
-
Safe Polymorphism: You can pass any
Shapeto functions expectingShapewithout checking the concrete type. -
Contract Honoring: Each class fully honors its interface contract -
getArea()always returns the correct area. -
Immutability Benefits: By making shapes immutable, we avoid the setter problem entirely.
-
Proper Modeling: Square and Rectangle are separate concepts that happen to share behavior (Shape), not a parent-child relationship.
-
Easier Testing: Tests for
Shapework for all implementations. No special cases needed. -
Design Clarity: The inheritance hierarchy reflects true "is-a" relationships, not just code reuse.