Skip to content
TypeScript ts oop 5 min read

SOLID Principles

SOLID is a set of five object-oriented design principles that keep classes small, focused, and easy to change. In a typed language like TypeScript they are especially powerful because the compiler can enforce the contracts you design — interfaces, narrow types, and dependency boundaries become checkable at build time. This page walks through each principle with a concrete TypeScript before (a design that violates it) and after (a refactor that honours it). The goal is not dogma but flexible, testable code.

Single Responsibility Principle (SRP)

A class should have one reason to change. When a class mixes unrelated concerns — business logic, persistence, and formatting — every new requirement risks breaking the others. Split responsibilities into collaborating units.

// ❌ Before: one class does validation, persistence, and email
class UserService {
  register(email: string) {
    if (!email.includes("@")) throw new Error("Invalid email");
    // ...writes directly to the database
    // ...sends a welcome email inline
  }
}

// ✅ After: each collaborator owns one responsibility
class EmailValidator {
  isValid(email: string): boolean {
    return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email);
  }
}

class UserRepository {
  save(email: string): void {
    /* persist */
  }
}

class Mailer {
  sendWelcome(email: string): void {
    /* send */
  }
}

Now a change to email rules touches only EmailValidator, and you can unit-test each piece in isolation. The orchestration moves to a thin coordinator that wires the collaborators together.

Open/Closed Principle (OCP)

Software entities should be open for extension but closed for modification. Adding a new variant should not mean editing a growing switch. TypeScript’s interfaces and union types make this clean.

// ❌ Before: every new shape edits this method
class AreaCalculator {
  area(shape: { kind: "circle" | "square"; size: number }): number {
    switch (shape.kind) {
      case "circle":
        return Math.PI * shape.size ** 2;
      case "square":
        return shape.size ** 2;
    }
  }
}

// ✅ After: extend by adding a class, not editing existing code
interface Shape {
  area(): number;
}

class Circle implements Shape {
  constructor(private readonly radius: number) {}
  area(): number {
    return Math.PI * this.radius ** 2;
  }
}

class Square implements Shape {
  constructor(private readonly side: number) {}
  area(): number {
    return this.side ** 2;
  }
}

const total = (shapes: Shape[]) =>
  shapes.reduce((sum, s) => sum + s.area(), 0);

Adding a Triangle means writing a new class that implements Shape — no existing file changes. The total function stays untouched.

Liskov Substitution Principle (LSP)

Subtypes must be usable anywhere their base type is expected, without surprises. A subclass that strengthens preconditions or throws where the parent did not breaks callers.

// ❌ Before: Penguin can't fly, violating the Bird contract
class Bird {
  fly(): string {
    return "flying";
  }
}
class Penguin extends Bird {
  override fly(): never {
    throw new Error("Penguins can't fly"); // breaks substitutability
  }
}

// ✅ After: model the real capabilities separately
interface Bird {
  layEgg(): void;
}
interface FlyingBird extends Bird {
  fly(): string;
}

class Sparrow implements FlyingBird {
  layEgg() {}
  fly() {
    return "flying";
  }
}
class Penguin implements Bird {
  layEgg() {}
}

By separating FlyingBird from Bird, a function that requires flight asks for FlyingBird, and Penguin is never wrongly substituted. The type system now prevents the invalid call site entirely.

Interface Segregation Principle (ISP)

Clients should not be forced to depend on methods they do not use. Fat interfaces leak unrelated requirements; small, role-based interfaces keep implementations honest.

// ❌ Before: a fat interface forces empty implementations
interface Machine {
  print(doc: string): void;
  scan(doc: string): void;
  fax(doc: string): void;
}

// ✅ After: split into focused capabilities
interface Printer {
  print(doc: string): void;
}
interface Scanner {
  scan(doc: string): void;
}

class SimplePrinter implements Printer {
  print(doc: string) {
    /* ... */
  }
}
class AllInOne implements Printer, Scanner {
  print(doc: string) {}
  scan(doc: string) {}
}

A basic printer implements only Printer. Composing Printer & Scanner describes a multifunction device without obliging every printer to support faxing.

Dependency Inversion Principle (DIP)

High-level modules should depend on abstractions, not on concrete low-level details. Inject dependencies through interfaces so implementations are swappable.

// ❌ Before: high-level class is glued to a concrete logger
class FileLogger {
  log(msg: string) {
    /* write to disk */
  }
}
class OrderService {
  private logger = new FileLogger(); // hard dependency
  place() {
    this.logger.log("order placed");
  }
}

// ✅ After: depend on an abstraction, inject the implementation
interface Logger {
  log(msg: string): void;
}
class OrderService {
  constructor(private readonly logger: Logger) {}
  place() {
    this.logger.log("order placed");
  }
}

OrderService no longer knows whether logs go to a file, the console, or a cloud service. Tests pass a fake Logger; production passes the real one. This principle is the foundation of dependency injection.

PrincipleOne-line rulePrimary TS tool
SRPOne reason to changeSmall classes/modules
OCPExtend, don’t modifyinterface + polymorphism
LSPSubtypes substitute safelyRole interfaces, override
ISPNo unused method depsMany small interfaces
DIPDepend on abstractionsConstructor injection

SOLID principles reinforce each other: DIP relies on the abstractions you define for OCP, and ISP keeps those abstractions small enough to honour LSP.

Best Practices

  • Treat each class’s “reason to change” as its job description — if you can name two, split it.
  • Prefer adding a new class over editing a switch when a requirement introduces a variant.
  • Design subclasses to honour their base contract; never throw where the parent succeeds.
  • Keep interfaces small and role-based so implementers are not forced into empty methods.
  • Depend on interfaces in constructors, not on concrete classes you instantiate with new.
  • Use SOLID as a lens during code review, not a checklist to apply mechanically everywhere.
  • Let the compiler enforce your design — well-chosen interfaces turn principles into checks.
Last updated June 29, 2026
Was this helpful?