Clean Code

Open-Closed Principle (OCP) Best Practices in TypeScript: Guidelines and Examples

Every time we added a new payment method, we modified the PaymentProcessor class. Add Stripe? Touch PaymentProcessor. Add PayPal? Touch PaymentProcessor. ...

19 Apr 2024

Open-Closed Principle (OCP) Best Practices in TypeScript: Guidelines and Examples

Every time we added a new payment method, we modified the PaymentProcessor class. Add Stripe? Touch PaymentProcessor. Add PayPal? Touch PaymentProcessor. Add crypto? Touch PaymentProcessor. Each change risked breaking the existing methods. Eventually, someone did break credit card processing while adding Apple Pay.

The Open-Closed Principle exists to prevent exactly this.

What OCP says

Software entities — classes, functions, modules — should be open for extension but closed for modification. You should be able to add new behavior without changing existing code.

This doesn't mean code is frozen forever. It means you design it so that new capabilities plug in rather than requiring surgery on the existing implementation.

How to apply OCP in TypeScript

Use abstractions and interfaces

Define contracts. Let different implementations fulfill them. New behavior means a new class, not a modified one.

Typescript
interface Shape {
  area(): number;
}

class Circle implements Shape {
  constructor(private radius: number) {}

  area(): number {
    return Math.PI * this.radius ** 2;
  }
}

class Rectangle implements Shape {
  constructor(private width: number, private height: number) {}

  area(): number {
    return this.width * this.height;
  }
}

Adding a Triangle means creating a new class that implements Shape. The existing Circle and Rectangle classes don't change. Nothing breaks.

Use dependency injection

Inject dependencies through constructors. The class depends on the abstraction, not the concrete implementation.

Typescript
class Logger {
  log(message: string): void {
    console.log(message);
  }
}

class ProductService {
  constructor(private logger: Logger) {}

  saveProduct(product: Product): void {
    this.logger.log(`Product saved: ${product.name}`);
  }
}

Want a different logging strategy? Inject a different logger. ProductService stays closed.

Use the Strategy pattern

Encapsulate algorithms behind an interface. Swap strategies at runtime without modifying the code that uses them.

Typescript
interface SortingStrategy {
  sort(items: any[]): any[];
}

class QuickSort implements SortingStrategy {
  sort(items: any[]): any[] {
    // quicksort implementation
    return items;
  }
}

class MergeSort implements SortingStrategy {
  sort(items: any[]): any[] {
    // merge sort implementation
    return items;
  }
}

class Sorter {
  constructor(private strategy: SortingStrategy) {}

  sort(items: any[]): any[] {
    return this.strategy.sort(items);
  }
}

New sorting algorithm? Create a new class. The Sorter never changes. See also: Strategy Design Pattern

Design extension points

Build hooks, events, or plugin systems that let external code add behavior without modifying the core.

Typescript
class Button {
  private handlers: (() => void)[] = [];

  onClick(callback: () => void): void {
    this.handlers.push(callback);
  }

  click(): void {
    this.handlers.forEach(handler => handler());
  }
}

const button = new Button();
button.onClick(() => console.log('Clicked!'));
button.onClick(() => analytics.track('button_click'));

The Button class is closed for modification. But it's open for extension — you can add any number of click handlers without touching the class.

The trade-off

OCP requires upfront design. You need to identify the axis of change — what's likely to vary — and build the extension point before you need it. Get it wrong and you've added abstraction for nothing. Get it right and adding features becomes trivially easy.

The risk is over-engineering. Not every class needs to be extensible. Apply OCP where you see a pattern of repeated modification. If a class changes every sprint, that's a signal it needs extension points. If it hasn't changed in a year, leave it alone.

Keep reading