Javier Chi Ortíz
Principios SOLID en TypeScript — con ejemplos de código reales
← Blog
Engineering

Principios SOLID en TypeScript — con ejemplos de código reales

8 min read · 21 de mayo de 2026

github.comSpidySamurai/SOLID_with_figures
View on GitHub →

Sin teoría. Un repositorio real en TypeScript que empieza roto y se arregla, un principio a la vez.


Toda oferta de trabajo para senior dev menciona SOLID. Todo code review lo referencia. La mayoría de las explicaciones se quedan en definiciones y ya.

Así que hice algo diferente: construí un repositorio que empieza con código genuinamente malo y aplica cada principio SOLID sobre el anterior — paso a paso, commit a commit — usando un ejemplo concreto que cualquiera puede seguir: figuras geométricas.


El problema — antes de SOLID

src/problem/

Problema: Las clases de figuras manejaban todo — calcular su propia área, imprimir resultados, y lo que fuera conveniente en el momento.

// src/problem — qué NO hacer
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()}`)
  }
}

Parece bien al principio. Pero agrega un triángulo. Luego un cuadrado. Luego el diseñador quiere output en JSON en vez de console.log. Ahora estás tocando cada clase de figura por una razón que no tiene nada que ver con geometría.

Cada principio en el repositorio corrige una capa de este problema.


S — Principio de Responsabilidad Única

src/principles/solid/srp/

Problema: Las clases de figuras manejaban tanto el cálculo del área como la impresión.

La solución es la más simple conceptualmente y la más impactante en la práctica: separar las responsabilidades.

// El único trabajo de Shape: conocer su propia área
class Rectangle {
  constructor(private width: number, private height: number) {}
  area(): number { return this.width * this.height }
}
 
// El único trabajo de AreaPrinter: imprimir áreas
class AreaPrinter {
  print(shape: Rectangle | Circle): void {
    console.log(`Area: ${shape.area()}`)
  }
}

Ahora si el formato de salida cambia — JSON, un componente de UI, un PDF — solo tocas AreaPrinter. Las clases de figuras quedan intactas. Cada clase tiene exactamente una razón para cambiar.


O — Principio Abierto/Cerrado

src/principles/solid/ocp/

Problema: Agregar una nueva figura (por ejemplo, Triangle) requería modificar AreaPrinter — el union type Rectangle | Circle tenía que convertirse en Rectangle | Circle | Triangle.

Solución: Introducir una abstracción Shape. AreaPrinter depende de la abstracción, no de clases concretas.

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()}`)
  }
}

Ahora puedes agregar un Pentagon, un Hexagon, un Dodecahedron — cero cambios en AreaPrinter. Abierto para extensión. Cerrado para modificación.


L — Principio de Sustitución de Liskov

src/principles/solid/lsp/

Problema: La violación clásica — Square extends Rectangle. Un cuadrado es un rectángulo matemáticamente — pero en código, si estableces el ancho independientemente del alto en un cuadrado, rompes su invariante. En cualquier lugar donde el código espera un Rectangle y recibe un Square, las cosas explotan inesperadamente.

Solución: Cada figura concreta implementa la interfaz Shape directamente, sin cadena de herencia que pueda violar la sustituibilidad.

// Sin herencia entre figuras — cada una implementa Shape independientemente
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 funciona de forma segura con cualquier Shape — garantizado.
function printArea(shape: Shape): void {
  console.log(`Area: ${shape.area()}`)
}

Cualquier Shape puede sustituir de forma segura a cualquier otra Shape donde se espera una Shape. Sin sorpresas en tiempo de ejecución.


I — Principio de Segregación de Interfaces

src/principles/solid/isp/

Problema: Una única interfaz Shape acumuló métodos: area(), perimeter(), draw(), serialize()... Los clientes que solo necesitaban área se veían obligados a depender de (e implementar) métodos que nunca usarían.

Solución: Dividir en interfaces enfocadas. Los clientes dependen solo de lo que realmente necesitan.

interface AreaCalculable {
  area(): number
}
 
interface PerimeterCalculable {
  perimeter(): number
}
 
// Una figura puede implementar ambas — o solo una
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 solo necesita AreaCalculable — nunca pidió el perímetro
class AreaPrinter {
  print(shape: AreaCalculable): void {
    console.log(`Area: ${shape.area()}`)
  }
}

Sin más "implementa esto o lanza un error." Interfaces más pequeñas. Dependencias honestas.


D — Principio de Inversión de Dependencias

src/principles/solid/dip/

Problema: AreaPrinter seguía acoplado — incluso con abstracciones, la instanciación de figuras ocurría dentro o cerca de la impresora, lo que significa que el módulo de alto nivel controlaba los detalles de bajo nivel.

Solución: AreaPrinter depende de la abstracción AreaCalculable inyectada desde afuera. Nunca crea figuras. Nunca necesita saber qué tipo de figura está manejando.

class AreaPrinter {
  // La dependencia se inyecta — AreaPrinter no depende de nada concreto
  constructor(private readonly shape: AreaCalculable) {}
 
  print(): void {
    console.log(`Area: ${this.shape.area()}`)
  }
}
 
// Composition root — el único lugar donde se mencionan clases concretas
const printer = new AreaPrinter(new Circle(5))
printer.print()
 
// Cambia la figura — cero cambios en AreaPrinter
const printer2 = new AreaPrinter(new Rectangle(4, 6))
printer2.print()

Los módulos de alto nivel (AreaPrinter) no dependen de módulos de bajo nivel (Circle, Rectangle). Ambos dependen de abstracciones (AreaCalculable). Esto es lo que hace que el código sea genuinamente testeable: inyecta un mock, no se necesitan figuras reales.


¿Por qué figuras?

Porque las figuras son universalmente entendidas. Sin conocimiento de dominio requerido, sin contexto de negocio que explicar — todos saben qué es un círculo y un rectángulo.

El punto del repositorio es aislar el principio del dominio. Una vez que ves cómo aplica cada corrección a las figuras, puedes aplicar el mismo razonamiento a tu UserService, tu PaymentProcessor, tu NotificationSender.

La estructura de carpetas cuenta la historia: empieza en problem/, recorre cada principio en orden, y observa cómo el código mejora con cada paso.


El repositorio completo

TypeScript, sin dependencias, cinco carpetas — una por principio. Fórkalo, rómpelo, agrega una nueva figura y ve qué archivos tienes que tocar (y cuáles no).

Y si estás construyendo un SaaS o necesitas un codebase listo para producción con esta mentalidad desde el inicio — eso es lo que hago.