The Liskov Substitution Principle (LSP) says that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.
In other words, a subclass should be able to substitute its superclass without breaking the functionality expected from the superclass.
Let’s consider an interface Vehicle
with methods startEngine()
and move()
.
We’ll create two classes, Car
and Bicycle
, that implement this interface.
Car
is fine because it correctly implements both methods, but Bicycle
violates LSP because it doesn’t implement startEngine()
properly.
interface Vehicle {
startEngine(): void;
move(): void;
}
class Car implements Vehicle {
startEngine() {
console.log('Engine started.');
}
move() {
console.log('Car is moving...');
}
}
class Bicycle implements Vehicle {
startEngine() {
throw new Error("Bicycles don't have engines!");
}
move() {
console.log('Bicycle is moving...');
}
}
Now, let’s add a function operateVehicle()
that attempts to start the engine
before moving the vehicle.
function operateVehicle(vehicle: Vehicle) {
vehicle.startEngine();
vehicle.move();
}
const car = new Car();
operateVehicle(car); // Engine started. Car is moving...
const bicycle = new Bicycle();
operateVehicle(bicycle); // throws an error
In the operateVehicle()
function, we expect all Vehicle
objects to have a
functioning startEngine()
method. However, when we try to use Bicycle
, it
throws an error because it doesn’t have an engine. This violates the LSP because
we cannot substitute Bicycle
for Vehicle
without causing an issue.
To follow the LSP, we can change the design to avoid using a method that doesn’t apply to all subclasses. One approach is to create another interface for engine-related functionality.
interface Vehicle {
move(): void;
}
interface EngineVehicle extends Vehicle {
startEngine(): void;
}
class Car implements EngineVehicle {
startEngine() {
console.log('Engine started.');
}
move() {
console.log('Car is moving...');
}
}
class Bicycle implements Vehicle {
move() {
console.log('Bicycle is moving...');
}
}
function operateVehicle(vehicle: EngineVehicle) {
vehicle.startEngine();
vehicle.move();
}
const car = new Car();
operateVehicle(car); // Engine started. Car is moving...
const bicycle = new Bicycle();
// operateVehicle(bicycle);
// ❌ This will not compile because Bicycle doesn't implement EngineVehicle
By separating the engine functionality into an EngineVehicle
interface, we
ensure that only vehicles that actually have engines implement the startEngine
method. This allows us to follow LSP, making our code cleaner and more
maintainable, while preventing errors when substituting incompatible classes.
Find this post helpful? Subscribe and get notified when I post something new!