Javier Chi Ortíz
SOLID Principles in TypeScript — with real code examples
← Blog
Engineering

SOLID Principles in TypeScript — with real code examples

8 min read · May 21, 2026

github.comSpidySamurai/SOLID_with_figures
View on GitHub →

No theory. A real TypeScript codebase that starts broken and gets fixed, one principle at a time.


Every senior dev job posting mentions SOLID. Every code review drops a reference to it. Most explanations stop at definitions and call it a day.

So I did something different: I built a repository that starts with genuinely bad code and applies each SOLID principle on top of the previous one — step by step, commit by commit — using a concrete example everyone can follow: geometric shapes.


The problem — before SOLID

src/problem/

Problem: Shape classes handled everything — calculating their own area, printing output, and whatever else seemed convenient.

// src/problem — what NOT to do
class Rectangle {
  constructor(private width: number, private height: number) {}
 
  area(): number {
    return this.width * this.height
  }
 
  print(): void {
    console.log(`Rectangle area: ${this.area()}`)
  }
}
 
class Circle {
  constructor(private radius: number) {}
 
  area(): number {
    return Math.PI * this.radius ** 2
  }
 
  print(): void {
    console.log(`Circle area: ${this.area()}`)
  }
}

Looks fine at first. But add a triangle. Then add a square. Then the designer wants JSON output instead of console logs. Now you're touching every shape class for a reason that has nothing to do with geometry.

Each principle in the repo fixes one layer of this problem.


S — Single Responsibility Principle

src/principles/solid/srp/

Problem: Shape classes handled both area calculation and printing.

The fix is the simplest conceptually and the most impactful in practice: split the responsibilities.

// Shape's only job: know its own area
class Rectangle {
  constructor(private width: number, private height: number) {}
  area(): number { return this.width * this.height }
}
 
// AreaPrinter's only job: print areas
class AreaPrinter {
  print(shape: Rectangle | Circle): void {
    console.log(`Area: ${shape.area()}`)
  }
}

Now if the output format changes — JSON, a UI component, a PDF — you touch AreaPrinter only. The shape classes stay untouched. Each class has exactly one reason to change.


O — Open/Closed Principle

src/principles/solid/ocp/

Problem: Adding a new shape (say, Triangle) required modifying AreaPrinter — the union type Rectangle | Circle had to become Rectangle | Circle | Triangle.

Fix: Introduce a Shape abstraction. AreaPrinter depends on the abstraction, not on concrete classes.

interface Shape {
  area(): number
}
 
class Rectangle implements Shape {
  constructor(private width: number, private height: number) {}
  area(): number { return this.width * this.height }
}
 
class Triangle implements Shape {
  constructor(private base: number, private height: number) {}
  area(): number { return (this.base * this.height) / 2 }
}
 
class AreaPrinter {
  print(shape: Shape): void {
    console.log(`Area: ${shape.area()}`)
  }
}

Now you can add a Pentagon, a Hexagon, a Dodecahedron — zero changes to AreaPrinter. Open for extension. Closed for modification.


L — Liskov Substitution Principle

src/principles/solid/lsp/

Problem: The classic violation — Square extends Rectangle. A square is a rectangle mathematically — but in code, if you set width independently of height on a square, you break its invariant. Anywhere the code expects a Rectangle and gets a Square, things blow up unexpectedly.

Fix: Each concrete shape implements the Shape interface directly, with no inheritance chain that could violate substitutability.

// No inheritance between shapes — each implements Shape independently
class Square implements Shape {
  constructor(private side: number) {}
  area(): number { return this.side ** 2 }
}
 
class Rectangle implements Shape {
  constructor(private width: number, private height: number) {}
  area(): number { return this.width * this.height }
}
 
// AreaPrinter works safely with any Shape — guaranteed.
function printArea(shape: Shape): void {
  console.log(`Area: ${shape.area()}`)
}

Any Shape can now safely substitute any other Shape wherever a Shape is expected. No runtime surprises.


I — Interface Segregation Principle

src/principles/solid/isp/

Problem: A single fat Shape interface accumulated methods: area(), perimeter(), draw(), serialize()... Clients that only needed area were forced to depend on (and implement) methods they would never use.

Fix: Split into focused interfaces. Clients depend only on what they actually need.

interface AreaCalculable {
  area(): number
}
 
interface PerimeterCalculable {
  perimeter(): number
}
 
// A shape can implement both — or just one
class Circle implements AreaCalculable, PerimeterCalculable {
  constructor(private radius: number) {}
  area(): number { return Math.PI * this.radius ** 2 }
  perimeter(): number { return 2 * Math.PI * this.radius }
}
 
// AreaPrinter only needs AreaCalculable — it never asked for perimeter
class AreaPrinter {
  print(shape: AreaCalculable): void {
    console.log(`Area: ${shape.area()}`)
  }
}

No more "implement this or throw an error." Smaller interfaces. Honest dependencies.


D — Dependency Inversion Principle

src/principles/solid/dip/

Problem: AreaPrinter was still tightly coupled — even with abstractions, the instantiation of shapes happened inside or near the printer, meaning the high-level module controlled the low-level details.

Fix: AreaPrinter depends on the AreaCalculable abstraction injected from outside. It never creates shapes. It never needs to know what kind of shape it is dealing with.

class AreaPrinter {
  // Dependency is injected — AreaPrinter depends on nothing concrete
  constructor(private readonly shape: AreaCalculable) {}
 
  print(): void {
    console.log(`Area: ${this.shape.area()}`)
  }
}
 
// Composition root — the only place where concrete classes are mentioned
const printer = new AreaPrinter(new Circle(5))
printer.print()
 
// Swap the shape — zero changes to AreaPrinter
const printer2 = new AreaPrinter(new Rectangle(4, 6))
printer2.print()

High-level modules (AreaPrinter) do not depend on low-level modules (Circle, Rectangle). Both depend on abstractions (AreaCalculable). This is what makes code genuinely testable: inject a mock, no real shapes needed.


Why shapes?

Because shapes are universally understood. No domain knowledge required, no business context to explain — everyone knows what a circle and a rectangle are.

The point of the repo is to isolate the principle from the domain. Once you see how each fix applies to shapes, you can apply the same thinking to your UserService, your PaymentProcessor, your NotificationSender.

The folder structure itself tells the story: start at problem/, walk through each principle in order, and watch the code get better with every step.


The full repo

TypeScript, no dependencies, five folders — one per principle. Fork it, break it, add a new shape and see which files you have to touch (and which you do not).

And if you are building a SaaS or need a production-ready codebase that is built with this mindset from the start — that is what I do.