- Published on
Mastering the Liskov Substitution Principle with Real-World TypeScript Examples
- β Bad Example: Violating LSP
- β Good Example: Applying LSP Correctly
- π How to Achieve the Liskov Substitution Principle
- π§ How to Detect LSP Violations
- π Benefits of Applying LSP
- π₯ Advanced Example: HTTP Request Handlers
- β Good Code: Respecting LSP
- π― Key Takeaways from This Example
- π§ Advanced Problem: Shape Area Calculator
- π Summary
Definition: Subtypes must be substitutable for their base types without altering program behavior.
If class B is a subclass of class A, then objects of type A may be replaced with objects of type B without altering the correctness of the program.
Key Points
- Inheritance and polymorphism (behavior consistency)
- Ensure substitutability of subclasses without breaking correctness
- Prevents unexpected behavior and violations in inheritance hierarchies
- Subclasses can seamlessly replace their base class without altering the program's functionality
In other words, a child class must behave in such a way that it doesn't break the functionality or expectations of its parent class.
β Bad Example: Violating LSP
Scenario:
You have a class Bird
with a method fly()
, and you create a Penguin
class as a subtype. But penguins can't fly!
class Bird {
fly() {
console.log("Flying high in the sky!");
}
}
class Sparrow extends Bird {}
class Penguin extends Bird {
fly() {
throw new Error("Penguins can't fly!");
}
}
function letTheBirdFly(bird: Bird) {
bird.fly();
}
const penguin = new Penguin();
letTheBirdFly(penguin); // β Breaks the system!
𧨠Problem:
- The client code expects all
Bird
instances to fly, butPenguin
breaks the contract. - This violates LSP and can cause runtime errors, making the system brittle and hard to test.
β Good Example: Applying LSP Correctly
Solution:
Refactor by introducing an abstraction that respects capabilities.
abstract class Bird {}
interface FlyingBird {
fly(): void;
}
class Sparrow extends Bird implements FlyingBird {
fly() {
console.log("Sparrow flying!");
}
}
class Penguin extends Bird {
swim() {
console.log("Penguin swimming!");
}
}
function letTheBirdFly(bird: FlyingBird) {
bird.fly();
}
const sparrow = new Sparrow();
letTheBirdFly(sparrow); // β
Works perfectly
const penguin = new Penguin();
// letTheBirdFly(penguin); β Not allowed at compile time
π How to Achieve the Liskov Substitution Principle
- Use interfaces or abstract classes to define clear contracts.
- Ensure subclasses fulfill the contract of the base class without weakening preconditions or strengthening postconditions.
- Don't override methods to throw exceptions for expected behavior.
- Design hierarchies around behavior, not taxonomy. (A penguin is a bird, but not a flying bird.)
π§ How to Detect LSP Violations
Ask yourself:
- β Does the subclass override a method to throw exceptions or skip behavior?
- β Do you have
if instanceof
checks in client code? - β Does a subclass break assumptions of the superclass?
- β Are your unit tests failing when a subclass replaces the parent?
If yes to any, you're likely violating LSP.
π Benefits of Applying LSP
Benefit | Explanation |
---|---|
π Polymorphism | Allows safe substitution of classes without changing behavior |
β Predictability | Reduces bugs caused by unexpected subclass behavior |
π§ͺ Testability | Easier unit testing and mocking |
βοΈ Maintainability | Easier to add new behavior without breaking existing code |
π Scalability | Promotes extensible system architecture |
π₯ Advanced Example: HTTP Request Handlers
β Bad Code: Violating LSP
You have a RequestHandler
base class, and you extend it for different request types like AuthenticatedRequestHandler
. But the subclass throws errors if the request isn't authenticated, breaking expectations.
class Request {
constructor(public headers: Record<string, string>) {}
}
class RequestHandler {
handle(req: Request): string {
return "Request handled";
}
}
class AuthenticatedRequestHandler extends RequestHandler {
handle(req: Request): string {
if (!req.headers["Authorization"]) {
throw new Error("Unauthorized request"); // β Violates LSP
}
return "Authenticated request handled";
}
}
function processRequest(handler: RequestHandler, req: Request) {
console.log(handler.handle(req));
}
const req1 = new Request({});
const handler1 = new AuthenticatedRequestHandler();
processRequest(handler1, req1); // β Throws unexpected error
π¨ Problem:
processRequest
expects anyRequestHandler
to handle a request safely.- But
AuthenticatedRequestHandler
changes the contractβit throws an error instead. - This breaks the substitutability, violating LSP.
β Good Code: Respecting LSP
β Solution: Separate concerns using interfaces or strategy pattern
class Request {
constructor(public headers: Record<string, string>) {}
}
interface IRequestHandler {
handle(req: Request): string;
}
class PublicRequestHandler implements IRequestHandler {
handle(req: Request): string {
return "Public request handled";
}
}
class AuthenticatedRequestHandler implements IRequestHandler {
handle(req: Request): string {
const token = req.headers["Authorization"];
return token
? "Authenticated request handled"
: "Guest access: limited features";
}
}
function processRequest(handler: IRequestHandler, req: Request) {
console.log(handler.handle(req));
}
const req1 = new Request({});
const req2 = new Request({ Authorization: "Bearer token" });
processRequest(new PublicRequestHandler(), req1); // β
Public request handled
processRequest(new AuthenticatedRequestHandler(), req1); // β
Guest access: limited features
processRequest(new AuthenticatedRequestHandler(), req2); // β
Authenticated request handled
π― Key Takeaways from This Example
Principle | Explanation |
---|---|
π LSP | Any IRequestHandler can be used in place of another without surprising behavior |
π‘οΈ Safety | No unexpected exceptions or broken flow |
π Detect Violation | If a subclass strengthens preconditions (like requiring headers), it's a sign of LSP violation |
π Fix | Create clearer contracts or segregate responsibilities using interfaces |
This pattern is very useful in middleware systems, API routers, or request pipelines where different handlers must conform to the same interface and expectations.
π§ Advanced Problem: Shape Area Calculator
β Bad Example (Violating LSP)
class Rectangle {
constructor(public width: number, public height: number) {}
setWidth(width: number) {
this.width = width;
}
setHeight(height: number) {
this.height = height;
}
getArea() {
return this.width * this.height;
}
}
class Square extends Rectangle {
setWidth(width: number) {
this.width = width;
this.height = width;
}
setHeight(height: number) {
this.width = height;
this.height = height;
}
}
function resize(rect: Rectangle) {
rect.setWidth(5);
rect.setHeight(4);
console.log(rect.getArea()); // Expected 20
}
resize(new Rectangle(2, 3)); // β
20
resize(new Square(2, 2)); // β 16
β Good Example (Respecting LSP)
interface Shape {
getArea(): number;
}
class Rectangle implements Shape {
constructor(public width: number, public height: number) {}
getArea() {
return this.width * this.height;
}
}
class Square implements Shape {
constructor(public size: number) {}
getArea() {
return this.size * this.size;
}
}
function printArea(shape: Shape) {
console.log(shape.getArea());
}
printArea(new Rectangle(5, 4)); // β
20
printArea(new Square(4)); // β
16
π Summary
Topic | Notes |
---|---|
β What is LSP | Subclasses should be replaceable for parent classes without altering program behavior. |
β Bad Example | Penguin flying or Square as a Rectangle violates LSP |
β Good Design | Use composition, interfaces, and behavior-centric design |
π Detection | Watch for exceptions, if-checks, or broken assumptions in subclasses |
π Fixes | Refactor using interfaces, split behaviors, rethink hierarchies |