Building Flexible Software: OCP and LSP Explained with TypeScript
Software development is often about managing complexity and change. As applications grow, how do we add new features without breaking existing ones? How do we ensure our code remains maintainable, flexible, and robust? Two core principles from the SOLID acronym offer powerful guidance: the Open/Closed Principle (OCP) and the Liskov Substitution Principle (LSP).
Let’s dive into what these principles mean, why they matter, and how TypeScript helps us implement them effectively.
The Open/Closed Principle (OCP): Open for Extension, Closed for Modification
Robert C. Martin defines OCP as:
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
This sounds a bit contradictory at first, doesn’t it? How can something be open and closed?
The key idea is that you should be able to add new functionality without changing existing, working code.
As noted, a class ideally never needs to change its core, tested logic. New functionality gets added by:
- Adding new subclasses: Extending existing classes.
- Adding new implementing classes: Implementing existing interfaces.
- Adding new methods: Though often, the goal is to avoid modifying existing methods.
- Reusing existing code through composition: Building complex behavior from smaller, stable parts.
Why is this important? Every time you modify existing code, you risk introducing new bugs into features that were previously working perfectly. By keeping stable code “closed” for modification, we prevent regressions and new defects. By keeping it “open” for extension, we allow the system to grow and adapt to new requirements gracefully. This often leads us towards using established design patterns.
OCP Violation Example
Imagine calculating the total area of different shapes:
// VIOLATION of OCP
class Rectangle {
constructor(public width: number, public height: number) {}
}
class Circle {
constructor(public radius: number) {}
}
// Area calculator needs modification for every new shape
function calculateTotalArea(shapes: (Rectangle | Circle)[]): number {
let totalArea = 0;
for (const shape of shapes) {
if (shape instanceof Rectangle) {
totalArea += shape.width * shape.height;
} else if (shape instanceof Circle) {
totalArea += Math.PI * shape.radius * shape.radius;
}
// 👎 What happens when we add Triangle? We MUST modify this function!
}
return totalArea;
}
const shapesToCalc = [new Rectangle(10, 5), new Circle(7)];
console.log(`Total area (bad way): ${calculateTotalArea(shapesToCalc)}`);
Adding a Triangle
class forces us to modify the calculateTotalArea
function. This violates OCP.
OCP Adherence Example (using Interfaces)
Let’s refactor using an interface:
// ADHERENCE to OCP
interface Shape {
getArea(): number; // Contract: All shapes must provide an area calculation
}
class Rectangle implements Shape {
constructor(public width: number, public height: number) {}
getArea(): number {
return this.width * this.height;
}
}
class Circle implements Shape {
constructor(public radius: number) {}
getArea(): number {
return Math.PI * this.radius * this.radius;
}
}
// ✨ New shape - no changes needed elsewhere! ✨
class Triangle implements Shape {
constructor(public base: number, public height: number) {}
getArea(): number {
return 0.5 * this.base * this.height;
}
}
// ✅ This function is now CLOSED for modification, OPEN for extension
function calculateTotalAreaOCP(shapes: Shape[]): number {
let totalArea = 0;
for (const shape of shapes) {
// Relies on the Shape contract, doesn't care about concrete type
totalArea += shape.getArea();
}
return totalArea;
}
const shapesToCalcOCP = [
new Rectangle(10, 5),
new Circle(7),
new Triangle(4, 6) // Easily added!
];
console.log(`Total area (OCP way): ${calculateTotalAreaOCP(shapesToCalcOCP)}`);
Now, calculateTotalAreaOCP
works with any object that fulfills the Shape
contract. Adding Triangle
(or Square
, Pentagon
, etc.) requires only creating the new class implementing Shape
. The existing, tested calculateTotalAreaOCP
function remains untouched – closed for modification, open for extension!
The Liskov Substitution Principle (LSP): Substitutability is Key
Barbara Liskov defined this principle as:
Let Φ(x) be a property provable about objects x of type T. Then Φ(y) should be true for objects y of type S where S is a subtype of T.
In simpler terms:
Subtypes must be substitutable for their base types without altering the correctness of the program.
If you have a function that accepts an object of type Base
, you should be able to pass in an object of type Derived
(where Derived
extends or implements Base
) without the function misbehaving or needing to know it specifically received a Derived
object.
LSP is crucial for making OCP work reliably through inheritance. If a subclass breaks the contract or assumptions of its base class, then simply extending the system with that subclass will require modifications elsewhere (often instanceof
checks or conditional logic) to handle the problematic subtype, thus violating OCP.
LSP Violation Example
The classic example involves rectangles and squares:
// VIOLATION of LSP (and consequently OCP)
class RectangleLSP {
constructor(protected width: number, protected height: number) {}
setWidth(width: number): void {
this.width = width;
}
setHeight(height: number): void {
this.height = height;
}
getArea(): number {
return this.width * this.height;
}
}
class Square extends RectangleLSP {
constructor(side: number) {
super(side, side);
}
// ❗ Override methods to maintain square property (width === height)
// ❗ This breaks the Rectangle's expected independent behavior of width/height
setWidth(width: number): void {
this.width = width;
this.height = width; // Keep it square
}
setHeight(height: number): void {
this.width = height; // Keep it square
this.height = height;
}
}
function useRectangle(rect: RectangleLSP) {
rect.setWidth(5);
rect.setHeight(10); // Expect area = 5 * 10 = 50
const area = rect.getArea();
console.log(`Expected area: 50, Actual area: ${area}`);
// Assertion (or simple check)
if (area !== 50) {
console.error("LSP Violation Detected! Subtype changed behavior unexpectedly.");
// This might lead to needing `if (rect instanceof Square)` checks elsewhere, violating OCP.
}
}
const realRectangle = new RectangleLSP(2, 3);
const supposedSquare = new Square(4);
useRectangle(realRectangle); // Works as expected (Area = 50)
useRectangle(supposedSquare); // Fails! (Area = 100, because setHeight also changed width)
Here, Square
is-not-quite-a Rectangle
in terms of behavior. A function expecting a Rectangle
might set width and height independently. A Square
breaks this expectation because setting one dimension forces the other to change. Passing a Square
where a Rectangle
is expected breaks the useRectangle
function’s logic. This violation forces you to add checks (if (rect instanceof Square)
) elsewhere, breaking OCP.
LSP Adherence Example
Often, violating LSP indicates a flawed abstraction or hierarchy. Solutions might involve:
- Not using inheritance: Maybe
Square
andRectangle
shouldn’t inherit from each other. They could both implement aShape
interface. - Making base classes immutable: If width/height couldn’t be changed after creation, the issue might disappear (depending on the exact requirements).
- Revising the contract: Define interfaces more precisely (e.g., separate interfaces for mutable vs. immutable shapes).
Let’s use the interface approach from the OCP example, which naturally avoids this LSP issue:
// ADHERENCE to LSP (by avoiding problematic inheritance)
interface ShapeLSP {
getArea(): number;
// Could add other common properties/methods if needed
}
// No inheritance between Rectangle and Square related to mutable dimensions
class RectangleGood implements ShapeLSP {
constructor(public readonly width: number, public readonly height: number) {} // Immutable
getArea(): number {
return this.width * this.height;
}
}
class SquareGood implements ShapeLSP {
constructor(public readonly side: number) {} // Immutable
getArea(): number {
return this.side * this.side;
}
}
// This function just needs something with getArea(), doesn't rely on mutable setters
function printArea(shape: ShapeLSP) {
console.log(`Shape area: ${shape.getArea()}`);
}
const rectGood = new RectangleGood(5, 10);
const squareGood = new SquareGood(7);
printArea(rectGood); // Prints 50
printArea(squareGood); // Prints 49
// No unexpected behavior, LSP is maintained.
// We can substitute any ShapeLSP implementation without breaking printArea.
By favoring interfaces and avoiding inheritance hierarchies where subclass behavior violates base class assumptions, we uphold LSP.
How LSP Enables OCP
LSP is often considered a prerequisite for achieving OCP through inheritance or interface implementation. If your subtypes (extensions) are perfectly substitutable (LSP compliant), then client code interacting with the base type doesn’t need modification when new subtypes are introduced. The system remains closed to modification but open to extension. Conversely, if LSP is violated, you will eventually need to modify client code to handle the misbehaving subtype, thus violating OCP.
Conclusion
The Open/Closed Principle and Liskov Substitution Principle are fundamental pillars for creating flexible, maintainable, and robust object-oriented software.
- OCP guides us to design components that can be extended with new functionality without modifying their existing, tested code, thereby reducing risk.
- LSP ensures that our extensions (subclasses or implementations) behave predictably and don’t break the contracts established by their base types, making OCP achievable.
TypeScript, with its strong typing system and support for interfaces and abstract classes, provides excellent tools for implementing these principles. By consciously applying OCP and LSP, you invest in the long-term health and scalability of your codebase. You build systems that welcome change rather than fearing it.