Inicio / TypeScript / Conceptos de Backend / POO: Fundamentos

POO: Fundamentos

Clases, objetos, constructores, modificadores de acceso, interfaces y clases abstractas con TypeScript.


Programación Orientada a Objetos: Fundamentos

¿Qué es la POO?

La Programación Orientada a Objetos es un paradigma que organiza el código en torno a objetos que combinan datos (atributos) y comportamiento (métodos). Es el paradigma dominante en el backend porque facilita modelar dominios complejos, reutilizar código y escribir sistemas mantenibles.

Los cuatro pilares son: encapsulamiento, herencia, polimorfismo y abstracción. En esta lección nos centramos en los fundamentos: clases, objetos, constructores y visibilidad.


Clases y objetos

Una clase es un plano o plantilla. Un objeto es una instancia concreta de esa clase.

class Product {
  id:    number;
  name:  string;
  price: number;

  constructor(id: number, name: string, price: number) {
    this.id    = id;
    this.name  = name;
    this.price = price;
  }

  getFormattedPrice(): string {
    return `$${this.price.toFixed(2)}`;
  }

  applyDiscount(percent: number): void {
    if (percent < 0 || percent > 100) {
      throw new Error('El descuento debe estar entre 0 y 100');
    }
    this.price = this.price * (1 - percent / 100);
  }
}

const laptop = new Product(1, 'Laptop Pro', 1200);
console.log(laptop.getFormattedPrice()); // $1200.00
laptop.applyDiscount(10);
console.log(laptop.getFormattedPrice()); // $1080.00

Modificadores de acceso

TypeScript (y la mayoría de lenguajes OO) tiene tres niveles de visibilidad:

Modificador Accesible desde
public Cualquier lugar (por defecto)
protected La clase y sus subclases
private Solo dentro de la clase
class BankAccount {
  private balance: number;
  protected owner:  string;
  public  id:       string;

  constructor(id: string, owner: string, initialBalance: number) {
    this.id      = id;
    this.owner   = owner;
    this.balance = initialBalance;
  }

  // Método público: interfaz hacia el exterior
  deposit(amount: number): void {
    this.validateAmount(amount);
    this.balance += amount;
  }

  withdraw(amount: number): void {
    this.validateAmount(amount);
    if (amount > this.balance) {
      throw new Error('Saldo insuficiente');
    }
    this.balance -= amount;
  }

  getBalance(): number {
    return this.balance; // solo exponemos lectura, no escritura directa
  }

  // Método privado: lógica interna que nadie debe llamar desde fuera
  private validateAmount(amount: number): void {
    if (amount <= 0) throw new Error('El monto debe ser positivo');
  }
}

const account = new BankAccount('ACC-001', 'Ana García', 1000);
account.deposit(500);
account.withdraw(200);
console.log(account.getBalance()); // 1300

// account.balance = 9999; ← Error: 'balance' is private

Getters y setters

Los getters y setters permiten controlar el acceso a propiedades privadas con lógica adicional:

class Temperature {
  private _celsius: number;

  constructor(celsius: number) {
    this._celsius = celsius;
  }

  get celsius(): number {
    return this._celsius;
  }

  set celsius(value: number) {
    if (value < -273.15) throw new Error('Por debajo del cero absoluto');
    this._celsius = value;
  }

  get fahrenheit(): number {
    return this._celsius * 9/5 + 32;
  }

  get kelvin(): number {
    return this._celsius + 273.15;
  }
}

const temp = new Temperature(25);
console.log(temp.fahrenheit); // 77
console.log(temp.kelvin);     // 298.15
temp.celsius = 100;
console.log(temp.fahrenheit); // 212

Propiedades y parámetros readonly

readonly garantiza que una propiedad no puede modificarse después de la inicialización:

class Point {
  constructor(
    public readonly x: number,
    public readonly y: number
  ) {}

  distanceTo(other: Point): number {
    return Math.sqrt((this.x - other.x) ** 2 + (this.y - other.y) ** 2);
  }

  toString(): string {
    return `(${this.x}, ${this.y})`;
  }
}

const p1 = new Point(0, 0);
const p2 = new Point(3, 4);
console.log(p1.distanceTo(p2)); // 5
// p1.x = 5; ← Error: Cannot assign to 'x' because it is a read-only property

Métodos estáticos y propiedades de clase

Los miembros static pertenecen a la clase, no a las instancias. Son útiles para factories, contadores y utilidades:

class IdGenerator {
  private static counter = 0;

  static next(): string {
    return `ID-${++IdGenerator.counter}`.padStart(8, '0');
  }

  static reset(): void {
    IdGenerator.counter = 0;
  }

  static getCurrent(): number {
    return IdGenerator.counter;
  }
}

console.log(IdGenerator.next()); // ID-000001
console.log(IdGenerator.next()); // ID-000002
console.log(IdGenerator.getCurrent()); // 2

Clases con interfaces

Las interfaces definen contratos: garantizan que una clase expone ciertos métodos sin importar su implementación interna.

interface Serializable {
  serialize(): string;
  toJSON(): Record<string, unknown>;
}

interface Validatable {
  isValid(): boolean;
  getErrors(): string[];
}

class UserProfile implements Serializable, Validatable {
  constructor(
    public readonly id: number,
    public name: string,
    public email: string,
    public age: number
  ) {}

  isValid(): boolean {
    return this.getErrors().length === 0;
  }

  getErrors(): string[] {
    const errors: string[] = [];
    if (!this.name || this.name.length < 2) errors.push('Nombre muy corto');
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.email)) errors.push('Email inválido');
    if (this.age < 0 || this.age > 150) errors.push('Edad inválida');
    return errors;
  }

  serialize(): string {
    return JSON.stringify(this.toJSON());
  }

  toJSON(): Record<string, unknown> {
    return { id: this.id, name: this.name, email: this.email, age: this.age };
  }
}

const user = new UserProfile(1, 'Ana', 'ana@example.com', 25);
console.log(user.isValid());   // true
console.log(user.serialize()); // '{"id":1,"name":"Ana",...}'

Clases abstractas

Las clases abstractas son un punto intermedio entre una clase normal y una interfaz: pueden tener implementación, pero no se pueden instanciar directamente:

abstract class Shape {
  constructor(public color: string) {}

  // Método abstracto: cada subclase DEBE implementarlo
  abstract area(): number;
  abstract perimeter(): number;

  // Método concreto: disponible para todas las subclases
  describe(): string {
    return `Figura de color ${this.color}: área=${this.area().toFixed(2)}, perímetro=${this.perimeter().toFixed(2)}`;
  }
}

class Circle extends Shape {
  constructor(color: string, private radius: number) {
    super(color);
  }

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

class Rectangle extends Shape {
  constructor(color: string, private width: number, private height: number) {
    super(color);
  }

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

// new Shape('rojo'); ← Error: Cannot create an instance of an abstract class
const circle = new Circle('azul', 5);
const rect   = new Rectangle('rojo', 4, 6);

console.log(circle.describe()); // Figura de color azul: área=78.54, perímetro=31.42
console.log(rect.describe());   // Figura de color rojo: área=24.00, perímetro=20.00

Composición vs Herencia

"Prefiere composición sobre herencia" — Gang of Four

La herencia modela relaciones "es-un". La composición modela relaciones "tiene-un". La composición es más flexible porque puedes cambiar el comportamiento en runtime.

// ❌ Herencia profunda — frágil y difícil de mantener
class Animal {}
class Vertebrate extends Animal {}
class Mammal extends Vertebrate {}
class DomesticMammal extends Mammal {}
class Dog extends DomesticMammal {}

// ✅ Composición — flexible y testeable
interface Logger {
  log(msg: string): void;
}

interface Notifier {
  notify(userId: string, msg: string): void;
}

class OrderService {
  constructor(
    private logger: Logger,
    private notifier: Notifier
  ) {}

  placeOrder(userId: string, amount: number): void {
    this.logger.log(`Orden creada: usuario=${userId}, monto=${amount}`);
    this.notifier.notify(userId, `Tu orden por $${amount} fue procesada`);
  }
}

Buenas prácticas

  • Una clase, una responsabilidad: si describes lo que hace una clase con "y", es señal de que tiene demasiadas.
  • Nombra en sustantivos: UserRepository, EmailSender, OrderCalculator.
  • Constructor limpio: no hagas I/O ni operaciones costosas en el constructor.
  • Métodos cortos: si un método tiene más de 20 líneas, considera refactorizarlo.
  • Evita setters innecesarios: expón comportamiento (withdraw()), no datos (setBalance()).

Ejercicio de práctica

Clase BankAccount

Clase BankAccount

Implementa una clase BankAccount que modele una cuenta bancaria con las siguientes características:

Requisitos:

  • Constructor que recibe owner: string y initialBalance: number
  • Propiedades privadas: _owner, _balance, _transactions
  • Getters de solo lectura: owner, balance, transactions
  • Método deposit(amount: number): añade fondos (lanza error si amount <= 0)
  • Método withdraw(amount: number): retira fondos (lanza error si fondos insuficientes o amount <= 0)
  • Método getStatement(): string: devuelve un resumen con el owner, balance y número de transacciones
  • Método estático compare(a: BankAccount, b: BankAccount): devuelve la cuenta con mayor balance

Ejemplo:

const acc = new BankAccount('Ana', 1000);
acc.deposit(500);
acc.withdraw(200);
console.log(acc.balance);        // 1300
console.log(acc.transactions);   // ['deposit: +500', 'withdraw: -200']
console.log(acc.getStatement());