JavaScript

JavaScript

Tutorial JavaScript: Clases y objetos

JavaScript clases objetos: creación y uso. Domina la creación y uso de clases y objetos en JavaScript con ejemplos prácticos y detallados.

Aprende JavaScript y certifícate

Fundamentos de la programación orientada a objetos en JavaScript: Sintaxis y semántica de clases

JavaScript ha evolucionado significativamente desde sus inicios, incorporando características de programación orientada a objetos (POO) que permiten estructurar el código de manera más organizada y reutilizable. Aunque JavaScript utiliza un sistema basado en prototipos, con la llegada de ES6 (ECMAScript 2015) se introdujo una sintaxis de clases que facilita la implementación de conceptos de POO de forma más intuitiva y familiar para desarrolladores provenientes de otros lenguajes.

Definición de clases

En JavaScript, una clase es esencialmente una plantilla para crear objetos. La sintaxis básica para definir una clase utiliza la palabra clave class seguida del nombre de la clase (por convención en PascalCase) y un bloque de código entre llaves:

class Person {
  // Contenido de la clase
}

Constructor

El constructor es un método especial que se ejecuta automáticamente cuando se crea una nueva instancia de la clase. Se utiliza para inicializar las propiedades del objeto:

class Person {
  constructor(firstName, lastName, age) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.age = age;
  }
}

El constructor recibe los parámetros necesarios y utiliza la palabra clave this para referirse a la instancia actual del objeto que se está creando.

Instanciación de objetos

Para crear un objeto a partir de una clase, utilizamos el operador new seguido del nombre de la clase:

const john = new Person('John', 'Doe', 30);
console.log(john.firstName); // Output: John
console.log(john.lastName);  // Output: Doe
console.log(john.age);       // Output: 30

Métodos de clase

Los métodos son funciones definidas dentro de la clase que describen el comportamiento de los objetos:

class Person {
  constructor(firstName, lastName, age) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.age = age;
  }
  
  getFullName() {
    return `${this.firstName} ${this.lastName}`;
  }
  
  greet() {
    return `Hello, my name is ${this.getFullName()} and I am ${this.age} years old.`;
  }
  
  celebrateBirthday() {
    this.age++;
    return `Happy Birthday! Now I am ${this.age} years old.`;
  }
}

const jane = new Person('Jane', 'Smith', 25);
console.log(jane.getFullName()); // Output: Jane Smith
console.log(jane.greet()); // Output: Hello, my name is Jane Smith and I am 25 years old.
console.log(jane.celebrateBirthday()); // Output: Happy Birthday! Now I am 26 years old.

Métodos estáticos

Los métodos estáticos pertenecen a la clase en sí, no a las instancias individuales. Se definen utilizando la palabra clave static y se invocan directamente desde la clase, sin necesidad de crear una instancia:

class MathUtils {
  static add(a, b) {
    return a + b;
  }
  
  static multiply(a, b) {
    return a * b;
  }
  
  static square(x) {
    return x * x;
  }
}

console.log(MathUtils.add(5, 3));      // Output: 8
console.log(MathUtils.multiply(4, 2)); // Output: 8
console.log(MathUtils.square(3));      // Output: 9

Los métodos estáticos son útiles para funcionalidades que están relacionadas conceptualmente con la clase pero no dependen del estado de una instancia específica.

Propiedades estáticas

De manera similar, podemos definir propiedades estáticas que pertenecen a la clase y no a las instancias:

class Config {
  static API_URL = 'https://api.example.com';
  static MAX_RETRY_ATTEMPTS = 3;
  static TIMEOUT_MS = 5000;
}

console.log(Config.API_URL); // Output: https://api.example.com
console.log(Config.MAX_RETRY_ATTEMPTS); // Output: 3

Getters y setters

JavaScript permite definir métodos de acceso (getters) y métodos de modificación (setters) para controlar el acceso a las propiedades de un objeto:

class Rectangle {
  constructor(width, height) {
    this._width = width;
    this._height = height;
  }
  
  // Getter para width
  get width() {
    return this._width;
  }
  
  // Setter para width
  set width(value) {
    if (value <= 0) {
      throw new Error('Width must be positive');
    }
    this._width = value;
  }
  
  // Getter para height
  get height() {
    return this._height;
  }
  
  // Setter para height
  set height(value) {
    if (value <= 0) {
      throw new Error('Height must be positive');
    }
    this._height = value;
  }
  
  // Getter calculado
  get area() {
    return this._width * this._height;
  }
}

const rect = new Rectangle(10, 5);
console.log(rect.width);  // Output: 10
console.log(rect.height); // Output: 5
console.log(rect.area);   // Output: 50

rect.width = 20;
console.log(rect.width);  // Output: 20
console.log(rect.area);   // Output: 100

// Esto lanzará un error
// rect.height = -5; // Error: Height must be positive

Los getters y setters se comportan como propiedades normales desde fuera de la clase, pero permiten ejecutar código cuando se accede a ellas o se modifican.

Campos de clase

Las versiones más recientes de JavaScript (a partir de ES2022) soportan la declaración de campos de clase directamente, sin necesidad de inicializarlos en el constructor:

class Product {
  id = Math.random().toString(36).substr(2, 9);
  name;
  price;
  
  constructor(name, price) {
    this.name = name;
    this.price = price;
  }
  
  getInfo() {
    return `${this.name}: $${this.price} (ID: ${this.id})`;
  }
}

const laptop = new Product('Laptop', 999.99);
console.log(laptop.getInfo()); // Output: Laptop: $999.99 (ID: x7f3e2q1z)

Campos privados

JavaScript también ha incorporado campos privados que solo son accesibles dentro de la clase. Se definen con un prefijo #:

class BankAccount {
  #balance = 0;
  #accountNumber;
  
  constructor(accountNumber, initialDeposit = 0) {
    this.#accountNumber = accountNumber;
    if (initialDeposit > 0) {
      this.deposit(initialDeposit);
    }
  }
  
  deposit(amount) {
    if (amount <= 0) {
      throw new Error('Deposit amount must be positive');
    }
    this.#balance += amount;
    return this.#balance;
  }
  
  withdraw(amount) {
    if (amount <= 0) {
      throw new Error('Withdrawal amount must be positive');
    }
    if (amount > this.#balance) {
      throw new Error('Insufficient funds');
    }
    this.#balance -= amount;
    return this.#balance;
  }
  
  get balance() {
    return this.#balance;
  }
  
  get accountNumber() {
    // Mostrar solo los últimos 4 dígitos por seguridad
    return `xxxx-xxxx-xxxx-${this.#accountNumber.slice(-4)}`;
  }
}

const account = new BankAccount('1234567890123456', 1000);
console.log(account.balance); // Output: 1000
console.log(account.accountNumber); // Output: xxxx-xxxx-xxxx-3456

account.deposit(500);
console.log(account.balance); // Output: 1500

account.withdraw(200);
console.log(account.balance); // Output: 1300

// Estos intentos de acceso directo generarían errores
// console.log(account.#balance); // Error: Private field '#balance' must be declared in an enclosing class
// console.log(account.#accountNumber); // Error: Private field '#accountNumber' must be declared in an enclosing class

Los campos privados proporcionan un verdadero encapsulamiento en JavaScript, impidiendo el acceso directo desde fuera de la clase.

Métodos privados

De manera similar, podemos definir métodos privados que solo son accesibles dentro de la clase:

class Calculator {
  #precision = 10;
  
  constructor(precision = 10) {
    this.#precision = precision;
  }
  
  add(a, b) {
    return this.#round(a + b);
  }
  
  subtract(a, b) {
    return this.#round(a - b);
  }
  
  multiply(a, b) {
    return this.#round(a * b);
  }
  
  divide(a, b) {
    if (b === 0) {
      throw new Error('Division by zero');
    }
    return this.#round(a / b);
  }
  
  // Método privado
  #round(value) {
    const factor = Math.pow(10, this.#precision);
    return Math.round(value * factor) / factor;
  }
}

const calc = new Calculator(2);
console.log(calc.add(0.1, 0.2)); // Output: 0.3 (en lugar de 0.30000000000000004)
console.log(calc.multiply(0.1, 0.3)); // Output: 0.03

// Esto generaría un error
// calc.#round(5.678); // Error: Private method '#round' must be declared in an enclosing class

Expresiones de clase

Las clases en JavaScript también pueden definirse como expresiones, lo que permite crear clases anónimas o asignarlas a variables:

// Clase anónima asignada a una variable
const Vehicle = class {
  constructor(type, speed) {
    this.type = type;
    this.speed = speed;
  }
  
  accelerate(increment) {
    this.speed += increment;
    return `${this.type} accelerating to ${this.speed} km/h`;
  }
};

const car = new Vehicle('Car', 0);
console.log(car.accelerate(50)); // Output: Car accelerating to 50 km/h

Esta característica es útil para crear clases dinámicamente o cuando se necesita pasar una clase como argumento a una función.

La sintaxis de clases en JavaScript proporciona una forma más clara y estructurada de implementar la programación orientada a objetos, aunque es importante recordar que bajo el capó sigue funcionando el sistema de prototipos que ha sido parte fundamental del lenguaje desde sus inicios.

Herencia y polimorfismo: Extensión de clases y organización jerárquica de comportamiento

La herencia es uno de los pilares fundamentales de la programación orientada a objetos que permite crear nuevas clases basadas en clases existentes. En JavaScript, la herencia se implementa mediante la palabra clave extends, que establece una relación jerárquica entre clases, donde la clase derivada (hija) hereda propiedades y métodos de la clase base (padre).

Herencia básica con extends

Para crear una clase que herede de otra, utilizamos la sintaxis extends:

class Animal {
  constructor(name) {
    this.name = name;
    this.energy = 100;
  }
  
  eat(amount) {
    this.energy += amount;
    return `${this.name} is eating. Energy: ${this.energy}`;
  }
  
  sleep(hours) {
    this.energy += hours * 10;
    return `${this.name} is sleeping. Energy: ${this.energy}`;
  }
  
  makeSound() {
    return `${this.name} makes a generic sound`;
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name); // Llamada al constructor de la clase padre
    this.breed = breed;
  }
  
  makeSound() {
    return `${this.name} barks loudly!`;
  }
  
  fetch() {
    this.energy -= 10;
    return `${this.name} is fetching. Energy: ${this.energy}`;
  }
}

En este ejemplo, Dog hereda todas las propiedades y métodos de Animal, y además añade su propia propiedad breed y método fetch(). La palabra clave super se utiliza para llamar al constructor de la clase padre.

La palabra clave super

El uso de super es crucial en la herencia por dos razones principales:

  1. Llamar al constructor padre: Debe ser la primera instrucción en el constructor de la clase hija.
constructor(name, breed) {
  super(name); // Llama al constructor de Animal
  this.breed = breed;
}
  1. Acceder a métodos del padre: Permite invocar métodos de la clase base desde la clase derivada.
class Cat extends Animal {
  constructor(name, furColor) {
    super(name);
    this.furColor = furColor;
  }
  
  makeSound() {
    // Llamamos al método makeSound() de la clase Animal
    const baseSound = super.makeSound();
    return `${baseSound}, but then meows`;
  }
  
  groom() {
    this.energy -= 5;
    return `${this.name} is grooming its ${this.furColor} fur. Energy: ${this.energy}`;
  }
}

const fluffy = new Cat('Fluffy', 'white');
console.log(fluffy.makeSound()); // Output: Fluffy makes a generic sound, but then meows
console.log(fluffy.groom()); // Output: Fluffy is grooming its white fur. Energy: 95

Cadenas de herencia

JavaScript permite crear cadenas de herencia donde una clase puede heredar de otra que a su vez hereda de una tercera:

class Mammal extends Animal {
  constructor(name, bodyTemperature) {
    super(name);
    this.bodyTemperature = bodyTemperature;
  }
  
  regulateTemperature() {
    return `${this.name} maintains a body temperature of ${this.bodyTemperature}°C`;
  }
}

class Wolf extends Mammal {
  constructor(name, packName) {
    super(name, 38);
    this.packName = packName;
  }
  
  howl() {
    this.energy -= 15;
    return `${this.name} howls to the moon! Energy: ${this.energy}`;
  }
  
  makeSound() {
    return `${this.name} growls and howls`;
  }
}

const alpha = new Wolf('Luna', 'Northern Pack');
console.log(alpha.regulateTemperature()); // Output: Luna maintains a body temperature of 38°C
console.log(alpha.howl()); // Output: Luna howls to the moon! Energy: 85
console.log(alpha.eat(20)); // Output: Luna is eating. Energy: 105

En este ejemplo, Wolf hereda de Mammal, que a su vez hereda de Animal, formando una jerarquía de clases que refleja relaciones naturales.

Polimorfismo en JavaScript

El polimorfismo permite que objetos de diferentes clases respondan al mismo método de manera distinta. En JavaScript, esto se logra principalmente mediante la sobrescritura de métodos:

// Usando las clases definidas anteriormente
const animals = [
  new Animal('Generic Animal'),
  new Dog('Rex', 'German Shepherd'),
  new Cat('Whiskers', 'tabby'),
  new Wolf('Timber', 'Mountain Pack')
];

// Polimorfismo en acción
animals.forEach(animal => {
  console.log(animal.makeSound());
});

// Output:
// Generic Animal makes a generic sound
// Rex barks loudly!
// Whiskers makes a generic sound, but then meows
// Timber growls and howls

Cada clase implementa el método makeSound() de manera diferente, pero podemos invocar el mismo método en todos los objetos independientemente de su tipo específico. Esta es la esencia del polimorfismo.

Verificación de instancias

Para determinar si un objeto es una instancia de una clase específica, utilizamos el operador instanceof:

const rex = new Dog('Rex', 'German Shepherd');

console.log(rex instanceof Dog);     // Output: true
console.log(rex instanceof Animal);  // Output: true
console.log(rex instanceof Object);  // Output: true
console.log(rex instanceof Cat);     // Output: false

El operador instanceof verifica toda la cadena de prototipos, por lo que un objeto es instancia no solo de su clase directa sino también de todas sus clases ancestras.

Herencia de métodos estáticos

Los métodos estáticos también se heredan en la jerarquía de clases:

class MathHelper {
  static PI = 3.14159;
  
  static square(x) {
    return x * x;
  }
  
  static cube(x) {
    return x * x * x;
  }
}

class AdvancedMathHelper extends MathHelper {
  static EULER = 2.71828;
  
  static squareRoot(x) {
    return Math.sqrt(x);
  }
  
  static power(base, exponent) {
    return Math.pow(base, exponent);
  }
}

console.log(AdvancedMathHelper.PI);        // Output: 3.14159 (heredado)
console.log(AdvancedMathHelper.EULER);     // Output: 2.71828
console.log(AdvancedMathHelper.square(4)); // Output: 16 (heredado)
console.log(AdvancedMathHelper.power(2, 3)); // Output: 8

Composición vs. Herencia

Aunque la herencia es poderosa, en JavaScript a menudo se prefiere la composición sobre la herencia para relaciones complejas:

// Enfoque de composición
class Engine {
  start() {
    return "Engine started";
  }
  
  stop() {
    return "Engine stopped";
  }
}

class Radio {
  turnOn() {
    return "Radio turned on";
  }
  
  turnOff() {
    return "Radio turned off";
  }
  
  changeStation(station) {
    return `Changed to station ${station}`;
  }
}

class Car {
  constructor(make, model) {
    this.make = make;
    this.model = model;
    this.engine = new Engine();
    this.radio = new Radio();
  }
  
  startCar() {
    return `${this.make} ${this.model}: ${this.engine.start()}`;
  }
  
  stopCar() {
    return `${this.make} ${this.model}: ${this.engine.stop()}`;
  }
  
  turnOnRadio() {
    return this.radio.turnOn();
  }
  
  changeRadioStation(station) {
    return this.radio.changeStation(station);
  }
}

const myCar = new Car('Toyota', 'Corolla');
console.log(myCar.startCar()); // Output: Toyota Corolla: Engine started
console.log(myCar.turnOnRadio()); // Output: Radio turned on

En este ejemplo, Car compone objetos Engine y Radio en lugar de heredar de ellos, lo que proporciona mayor flexibilidad y evita problemas asociados con jerarquías de herencia profundas.

Herencia múltiple y mixins

JavaScript no soporta herencia múltiple directamente, pero podemos simularla mediante mixins:

// Definimos mixins como objetos con métodos
const swimMixin = {
  swim() {
    return `${this.name} is swimming`;
  },
  dive() {
    return `${this.name} is diving underwater`;
  }
};

const flyMixin = {
  fly() {
    return `${this.name} is flying`;
  },
  soar() {
    return `${this.name} is soaring high in the sky`;
  }
};

// Función para aplicar mixins a una clase
function applyMixins(targetClass, ...mixins) {
  mixins.forEach(mixin => {
    Object.getOwnPropertyNames(mixin).forEach(name => {
      targetClass.prototype[name] = mixin[name];
    });
  });
}

// Clase base
class Bird extends Animal {
  constructor(name, wingSpan) {
    super(name);
    this.wingSpan = wingSpan;
  }
  
  makeSound() {
    return `${this.name} chirps`;
  }
}

// Aplicamos mixins a la clase Bird
applyMixins(Bird, flyMixin);

// Clase que necesita múltiples comportamientos
class Duck extends Bird {
  constructor(name) {
    super(name, 30);
  }
  
  makeSound() {
    return `${this.name} quacks`;
  }
}

// Aplicamos el mixin de natación a Duck
applyMixins(Duck, swimMixin);

const donald = new Duck('Donald');
console.log(donald.makeSound()); // Output: Donald quacks
console.log(donald.fly()); // Output: Donald is flying
console.log(donald.swim()); // Output: Donald is swimming

Los mixins permiten compartir funcionalidad entre clases sin necesidad de establecer relaciones de herencia, lo que resulta especialmente útil en JavaScript donde cada objeto solo puede tener un prototipo directo.

Herencia y el contexto de this

Es importante entender cómo funciona el contexto de this en la herencia:

class Logger {
  log(message) {
    console.log(`[LOG] ${message}`);
  }
  
  info(message) {
    this.log(`INFO: ${message}`);
  }
  
  error(message) {
    this.log(`ERROR: ${message}`);
  }
}

class DateLogger extends Logger {
  log(message) {
    const timestamp = new Date().toISOString();
    super.log(`[${timestamp}] ${message}`);
  }
}

const logger = new DateLogger();
logger.info("Application started"); 
// Output: [LOG] [2023-05-15T14:30:45.123Z] INFO: Application started

Cuando info() se llama en una instancia de DateLogger, el this dentro de info() se refiere a esa instancia, por lo que this.log() invoca la versión sobrescrita de log() en DateLogger, no la versión original en Logger.

La herencia y el polimorfismo son herramientas poderosas que permiten crear código más organizado, reutilizable y mantenible. Sin embargo, es importante utilizarlas juiciosamente, considerando alternativas como la composición cuando sea apropiado para evitar jerarquías de clases demasiado complejas o rígidas.

Patrones de diseño y técnicas avanzadas: Encapsulación, propiedades privadas y métodos de composición

La programación orientada a objetos en JavaScript moderno ofrece técnicas avanzadas que permiten crear código más robusto, mantenible y escalable. En esta sección exploraremos patrones de diseño esenciales y mecanismos de encapsulación que elevan la calidad del código orientado a objetos.

Encapsulación efectiva

La encapsulación es un principio fundamental que consiste en ocultar los detalles internos de implementación y exponer solo lo necesario. JavaScript ofrece varias técnicas para lograr este objetivo:

Campos privados con # (ES2022+)

La forma más moderna y recomendada de implementar encapsulación es mediante el uso de campos privados con el prefijo #:

class BankAccount {
  #balance = 0;
  #transactionHistory = [];
  
  constructor(initialBalance = 0) {
    if (initialBalance > 0) {
      this.deposit(initialBalance);
    }
  }
  
  deposit(amount) {
    if (amount <= 0) throw new Error("Deposit amount must be positive");
    
    this.#balance += amount;
    this.#recordTransaction("deposit", amount);
    return this.#balance;
  }
  
  withdraw(amount) {
    if (amount <= 0) throw new Error("Withdrawal amount must be positive");
    if (amount > this.#balance) throw new Error("Insufficient funds");
    
    this.#balance -= amount;
    this.#recordTransaction("withdrawal", amount);
    return this.#balance;
  }
  
  #recordTransaction(type, amount) {
    this.#transactionHistory.push({
      type,
      amount,
      date: new Date(),
      balance: this.#balance
    });
  }
  
  getBalance() {
    return this.#balance;
  }
  
  getTransactionHistory() {
    // Devolvemos una copia para evitar modificaciones externas
    return [...this.#transactionHistory];
  }
}

const account = new BankAccount(1000);
console.log(account.getBalance()); // Output: 1000
account.deposit(500);
account.withdraw(200);
console.log(account.getBalance()); // Output: 1300
console.log(account.getTransactionHistory().length); // Output: 3

// Estos intentos generarían errores
// console.log(account.#balance); // Error: Private field '#balance' must be declared in an enclosing class
// account.#recordTransaction("hack", 1000); // Error: Private method is not accessible

Los campos privados con # proporcionan un verdadero encapsulamiento a nivel de lenguaje, impidiendo completamente el acceso desde fuera de la clase.

Closures para encapsulación (patrón de módulo)

Antes de la introducción de campos privados, una técnica común era utilizar closures para crear variables privadas:

function createCounter() {
  // Variable privada mediante closure
  let count = 0;
  
  return {
    increment() {
      return ++count;
    },
    decrement() {
      return --count;
    },
    getValue() {
      return count;
    },
    reset() {
      count = 0;
      return count;
    }
  };
}

const counter = createCounter();
console.log(counter.getValue()); // Output: 0
counter.increment();
counter.increment();
console.log(counter.getValue()); // Output: 2
counter.reset();
console.log(counter.getValue()); // Output: 0

// La variable count no es accesible directamente
// console.log(counter.count); // Output: undefined

Este patrón, conocido como patrón de módulo, sigue siendo útil en ciertos contextos y para compatibilidad con navegadores antiguos.

Convención de nombres con guion bajo

Aunque no proporciona una verdadera protección, la convención de nombres con guion bajo sigue siendo común para indicar que una propiedad debe tratarse como privada:

class User {
  constructor(username, email) {
    this._username = username;
    this._email = email;
    this._lastLoginTimestamp = null;
  }
  
  login() {
    this._lastLoginTimestamp = Date.now();
    return `${this._username} logged in successfully`;
  }
  
  getLastLoginDate() {
    if (!this._lastLoginTimestamp) return "Never logged in";
    return new Date(this._lastLoginTimestamp).toLocaleString();
  }
}

Esta convención es puramente semántica y depende de la disciplina del equipo de desarrollo.

Patrones de diseño comunes en JavaScript

Los patrones de diseño son soluciones probadas a problemas recurrentes. Veamos algunos patrones particularmente útiles en JavaScript:

Patrón Singleton

El patrón Singleton garantiza que una clase tenga una única instancia y proporciona un punto de acceso global a ella:

class DatabaseConnection {
  static #instance = null;
  #connectionString;
  #isConnected = false;
  
  constructor(connectionString) {
    if (DatabaseConnection.#instance) {
      return DatabaseConnection.#instance;
    }
    
    this.#connectionString = connectionString;
    DatabaseConnection.#instance = this;
  }
  
  connect() {
    if (this.#isConnected) {
      return "Already connected";
    }
    
    // Lógica para conectar a la base de datos
    console.log(`Connecting to: ${this.#connectionString}`);
    this.#isConnected = true;
    return "Connection established";
  }
  
  disconnect() {
    if (!this.#isConnected) {
      return "Not connected";
    }
    
    // Lógica para desconectar
    this.#isConnected = false;
    return "Disconnected successfully";
  }
  
  executeQuery(query) {
    if (!this.#isConnected) {
      throw new Error("Must connect before executing queries");
    }
    
    console.log(`Executing: ${query}`);
    return `Results for: ${query}`;
  }
}

// Ambas variables referencian la misma instancia
const db1 = new DatabaseConnection("mongodb://localhost:27017/myapp");
const db2 = new DatabaseConnection("postgres://user:pass@localhost:5432/db");

console.log(db1 === db2); // Output: true

db1.connect();
console.log(db2.executeQuery("SELECT * FROM users")); // Funciona porque es la misma instancia

Este patrón es útil para recursos compartidos como conexiones a bases de datos, configuraciones globales o caches.

Patrón Factory

El patrón Factory encapsula la lógica de creación de objetos, permitiendo crear diferentes tipos de objetos basados en parámetros:

// Clases de productos
class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
    this.type = "rectangle";
  }
  
  getArea() {
    return this.width * this.height;
  }
  
  getPerimeter() {
    return 2 * (this.width + this.height);
  }
}

class Circle {
  constructor(radius) {
    this.radius = radius;
    this.type = "circle";
  }
  
  getArea() {
    return Math.PI * this.radius * this.radius;
  }
  
  getPerimeter() {
    return 2 * Math.PI * this.radius;
  }
}

class Triangle {
  constructor(a, b, c, height) {
    this.a = a;
    this.b = b;
    this.c = c;
    this.height = height;
    this.type = "triangle";
  }
  
  getArea() {
    return (this.b * this.height) / 2;
  }
  
  getPerimeter() {
    return this.a + this.b + this.c;
  }
}

// Factory
class ShapeFactory {
  createShape(shapeType, ...args) {
    switch(shapeType.toLowerCase()) {
      case "rectangle":
        return new Rectangle(args[0], args[1]);
      case "circle":
        return new Circle(args[0]);
      case "triangle":
        return new Triangle(args[0], args[1], args[2], args[3]);
      default:
        throw new Error(`Shape type "${shapeType}" not supported`);
    }
  }
}

// Uso
const factory = new ShapeFactory();
const shapes = [
  factory.createShape("rectangle", 10, 5),
  factory.createShape("circle", 7),
  factory.createShape("triangle", 5, 8, 7, 4)
];

shapes.forEach(shape => {
  console.log(`${shape.type} - Area: ${shape.getArea()}, Perimeter: ${shape.getPerimeter()}`);
});

Este patrón facilita la creación de objetos complejos y permite añadir nuevos tipos sin modificar el código cliente.

Patrón Observer

El patrón Observer establece una relación uno-a-muchos entre objetos, de modo que cuando un objeto cambia de estado, todos sus dependientes son notificados:

class EventEmitter {
  #events = new Map();
  
  on(eventName, listener) {
    if (!this.#events.has(eventName)) {
      this.#events.set(eventName, []);
    }
    
    this.#events.get(eventName).push(listener);
    return this; // Para encadenamiento
  }
  
  off(eventName, listener) {
    if (!this.#events.has(eventName)) return this;
    
    const listeners = this.#events.get(eventName);
    const index = listeners.indexOf(listener);
    
    if (index !== -1) {
      listeners.splice(index, 1);
    }
    
    return this;
  }
  
  emit(eventName, ...args) {
    if (!this.#events.has(eventName)) return false;
    
    const listeners = this.#events.get(eventName);
    listeners.forEach(listener => {
      listener(...args);
    });
    
    return true;
  }
  
  once(eventName, listener) {
    const onceWrapper = (...args) => {
      listener(...args);
      this.off(eventName, onceWrapper);
    };
    
    return this.on(eventName, onceWrapper);
  }
}

// Ejemplo de uso
class ShoppingCart extends EventEmitter {
  #items = [];
  
  addItem(item) {
    this.#items.push(item);
    this.emit("itemAdded", item);
    this.emit("cartUpdated", this.#items);
  }
  
  removeItem(index) {
    const removedItem = this.#items.splice(index, 1)[0];
    this.emit("itemRemoved", removedItem, index);
    this.emit("cartUpdated", this.#items);
  }
  
  getItems() {
    return [...this.#items]; // Devolvemos una copia
  }
}

// Uso
const cart = new ShoppingCart();

cart.on("itemAdded", item => {
  console.log(`Added: ${item.name} - $${item.price}`);
});

cart.on("cartUpdated", items => {
  console.log(`Cart now has ${items.length} items, total: $${
    items.reduce((sum, item) => sum + item.price, 0).toFixed(2)
  }`);
});

cart.addItem({ name: "Laptop", price: 999.99 });
cart.addItem({ name: "Headphones", price: 149.50 });
cart.removeItem(0);

Este patrón es la base de muchas implementaciones de eventos en JavaScript, incluido el sistema de eventos del DOM.

Composición sobre herencia

Aunque la herencia es útil, la composición ofrece mayor flexibilidad y evita problemas como la herencia múltiple o jerarquías demasiado profundas:

// Componentes reutilizables
class Database {
  constructor(connectionString) {
    this.connectionString = connectionString;
  }
  
  query(sql) {
    console.log(`Executing query: ${sql}`);
    return [`Result 1`, `Result 2`];
  }
}

class Logger {
  log(message) {
    console.log(`[${new Date().toISOString()}] ${message}`);
  }
  
  error(message) {
    console.error(`[ERROR] ${message}`);
  }
}

class EmailService {
  sendEmail(to, subject, body) {
    console.log(`Sending email to ${to}: ${subject}`);
    return true;
  }
}

// Clase que utiliza composición
class UserService {
  constructor() {
    this.db = new Database("users_db_connection");
    this.logger = new Logger();
    this.emailService = new EmailService();
  }
  
  createUser(userData) {
    try {
      this.logger.log(`Creating user: ${userData.email}`);
      const result = this.db.query(`INSERT INTO users VALUES (${JSON.stringify(userData)})`);
      this.emailService.sendEmail(
        userData.email,
        "Welcome to our platform",
        "Your account has been created successfully!"
      );
      return { success: true, userId: 123 };
    } catch (error) {
      this.logger.error(`Failed to create user: ${error.message}`);
      return { success: false, error: error.message };
    }
  }
  
  getUserById(id) {
    this.logger.log(`Fetching user with ID: ${id}`);
    return this.db.query(`SELECT * FROM users WHERE id = ${id}`)[0];
  }
}

// Uso
const userService = new UserService();
const newUser = {
  name: "John Doe",
  email: "john@example.com",
  role: "user"
};

const result = userService.createUser(newUser);
console.log(result);

En este ejemplo, UserService compone funcionalidades de diferentes clases en lugar de heredar de ellas, lo que proporciona mayor flexibilidad y facilita los cambios futuros.

Mixins para compartir comportamiento

Los mixins son una forma de compartir métodos entre clases sin usar herencia:

// Definición de mixins
const timestampMixin = {
  getCreatedAt() {
    return this._createdAt;
  },
  
  getUpdatedAt() {
    return this._updatedAt;
  },
  
  markAsUpdated() {
    this._updatedAt = new Date();
  }
};

const serializableMixin = {
  toJSON() {
    const json = {};
    
    // Convertimos solo propiedades no privadas (sin _)
    Object.keys(this).forEach(key => {
      if (!key.startsWith('_')) {
        json[key] = this[key];
      }
    });
    
    return json;
  },
  
  fromJSON(json) {
    Object.keys(json).forEach(key => {
      this[key] = json[key];
    });
    return this;
  }
};

// Función para aplicar mixins
function applyMixins(targetClass, ...mixins) {
  mixins.forEach(mixin => {
    Object.getOwnPropertyNames(mixin).forEach(name => {
      Object.defineProperty(
        targetClass.prototype,
        name,
        Object.getOwnPropertyDescriptor(mixin, name)
      );
    });
  });
}

// Clase que utiliza mixins
class Task {
  constructor(title, description) {
    this.title = title;
    this.description = description;
    this.completed = false;
    this._createdAt = new Date();
    this._updatedAt = new Date();
  }
  
  complete() {
    this.completed = true;
    this.markAsUpdated(); // Método del mixin
  }
}

// Aplicamos los mixins
applyMixins(Task, timestampMixin, serializableMixin);

// Uso
const task = new Task("Learn JavaScript", "Study advanced OOP concepts");
console.log(task.getCreatedAt()); // Método del mixin timestampMixin

task.complete();
console.log(task.getUpdatedAt()); // La fecha se actualizó al llamar a markAsUpdated()

const json = task.toJSON(); // Método del mixin serializableMixin
console.log(json); // No incluye propiedades que empiezan con _

Los mixins son especialmente útiles para compartir comportamientos transversales como logging, serialización o validación.

Propiedades computadas y métodos de acceso

Las propiedades computadas permiten definir valores que se calculan dinámicamente:

class Product {
  #price;
  #taxRate = 0.21; // 21% IVA
  
  constructor(name, price) {
    this.name = name;
    this.#price = price;
  }
  
  get price() {
    return this.#price;
  }
  
  set price(newPrice) {
    if (newPrice < 0) throw new Error("Price cannot be negative");
    this.#price = newPrice;
  }
  
  get taxRate() {
    return this.#taxRate;
  }
  
  set taxRate(rate) {
    if (rate < 0 || rate > 1) throw new Error("Tax rate must be between 0 and 1");
    this.#taxRate = rate;
  }
  
  // Propiedad computada
  get priceWithTax() {
    return this.#price * (1 + this.#taxRate);
  }
  
  // Propiedad computada con formato
  get formattedPrice() {
    return `$${this.#price.toFixed(2)}`;
  }
  
  get formattedPriceWithTax() {
    return `$${this.priceWithTax.toFixed(2)}`;
  }
}

const laptop = new Product("MacBook Pro", 1299);
console.log(laptop.price); // Output: 1299
console.log(laptop.priceWithTax); // Output: 1571.79
console.log(laptop.formattedPriceWithTax); // Output: $1571.79

laptop.price = 1399;
console.log(laptop.priceWithTax); // Output: 1692.79

laptop.taxRate = 0.10; // 10%
console.log(laptop.priceWithTax); // Output: 1538.90

Los getters y setters permiten validar datos y calcular valores derivados de forma transparente para el usuario de la clase.

Métodos de cadena (Method Chaining)

El encadenamiento de métodos mejora la legibilidad del código permitiendo llamar a múltiples métodos en secuencia:

class QueryBuilder {
  #table = '';
  #conditions = [];
  #fields = ['*'];
  #orderBy = '';
  #limit = null;
  
  from(table) {
    this.#table = table;
    return this; // Retornamos this para permitir encadenamiento
  }
  
  select(...fields) {
    if (fields.length > 0) {
      this.#fields = fields;
    }
    return this;
  }
  
  where(condition) {
    this.#conditions.push(condition);
    return this;
  }
  
  orderBy(field, direction = 'ASC') {
    this.#orderBy = `${field} ${direction}`;
    return this;
  }
  
  limit(count) {
    this.#limit = count;
    return this;
  }
  
  build() {
    let query = `SELECT ${this.#fields.join(', ')} FROM ${this.#table}`;
    
    if (this.#conditions.length > 0) {
      query += ` WHERE ${this.#conditions.join(' AND ')}`;
    }
    
    if (this.#orderBy) {
      query += ` ORDER BY ${this.#orderBy}`;
    }
    
    if (this.#limit !== null) {
      query += ` LIMIT ${this.#limit}`;
    }
    
    return query;
  }
}

// Uso con encadenamiento de métodos
const query = new QueryBuilder()
  .select('id', 'name', 'email')
  .from('users')
  .where('status = "active"')
  .where('last_login > "2023-01-01"')
  .orderBy('name')
  .limit(10)
  .build();

console.log(query);
// Output: SELECT id, name, email FROM users WHERE status = "active" AND last_login > "2023-01-01" ORDER BY name ASC LIMIT 10

Este patrón es común en bibliotecas de JavaScript como jQuery, Lodash o frameworks de bases de datos como Knex.js.

Inmutabilidad y objetos congelados

La inmutabilidad es un concepto importante en programación funcional que también se puede aplicar en POO:

class ImmutablePoint {
  #x;
  #y;
  
  constructor(x, y) {
    this.#x = x;
    this.#y = y;
    
    // Congelamos la instancia para prevenir la adición de propiedades
    Object.freeze(this);
  }
  
  get x() {
    return this.#x;
  }
  
  get y() {
    return this.#y;
  }
  
  // En lugar de modificar el objeto, creamos uno nuevo
  translate(dx, dy) {
    return new ImmutablePoint(this.#x + dx, this.#y + dy);
  }
  
  scale(factor) {
    return new ImmutablePoint(this.#x * factor, this.#y * factor);
  }
  
  toString() {
    return `(${this.#x}, ${this.#y})`;
  }
}

const p1 = new ImmutablePoint(10, 20);
console.log(p1.toString()); // Output: (10, 20)

// Las operaciones devuelven nuevos objetos
const p2 = p1.translate(5, -3);
console.log(p1.toString()); // Output: (10, 20) - el original no cambia
console.log(p2.toString()); // Output: (15, 17)

// No podemos añadir propiedades
p1.z = 30;
console.log(p1.z); // Output: undefined

La inmutabilidad facilita el razonamiento sobre el código, especialmente en entornos concurrentes o aplicaciones complejas.

Delegación de comportamiento

La delegación es un patrón donde un objeto delega operaciones a otro objeto:

class InputValidator {
  static validate(value, rules) {
    const errors = [];
    
    if (rules.required && !value) {
      errors.push("This field is required");
    }
    
    if (rules.minLength && value.length < rules.minLength) {
      errors.push(`Minimum length is ${rules.minLength} characters`);
    }
    
    if (rules.maxLength && value.length > rules.maxLength) {
      errors.push(`Maximum length is ${rules.maxLength} characters`);
    }
    
    if (rules.pattern && !rules.pattern.test(value)) {
      errors.push(rules.patternMessage || "Invalid format");
    }
    
    return errors;
  }
}

class FormField {
  #value = "";
  #validator;
  #validationRules;
  
  constructor(name, validationRules = {}) {
    this.name = name;
    this.#validationRules = validationRules;
    this.#validator = InputValidator; // Delegamos la validación
  }
  
  setValue(value) {
    this.#value = value;
    return this;
  }
  
  getValue() {
    return this.#value;
  }
  
  validate() {
    // Delegamos la validación a otra clase
    return this.#validator.validate(this.#value, this.#validationRules);
  }
}

// Uso
const emailField = new FormField("email", {
  required: true,
  pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
  patternMessage: "Please enter a valid email address"
});

emailField.setValue("invalid-email");
const errors = emailField.validate();
console.log(errors); // Output: ["Please enter a valid email address"]

emailField.setValue("user@example.com");
console.log(emailField.validate()); // Output: [] (sin errores)

La delegación permite separar responsabilidades y reutilizar código sin necesidad de herencia.

Los patrones de diseño y técnicas avanzadas presentados en esta sección proporcionan herramientas poderosas para crear código JavaScript orientado a objetos que sea mantenible, extensible y robusto. La elección entre herencia, composición, mixins o delegación dependerá del contexto específico y de los requisitos del sistema que estés desarrollando.

CONSTRUYE TU CARRERA EN IA Y PROGRAMACIÓN SOFTWARE

Accede a +1000 lecciones y cursos con certificado. Mejora tu portfolio con certificados de superación para tu CV.

30 % DE DESCUENTO

Plan mensual

19.00 /mes

13.30 € /mes

Precio normal mensual: 19 €
63 % DE DESCUENTO

Plan anual

10.00 /mes

7.00 € /mes

Ahorras 144 € al año
Precio normal anual: 120 €
Aprende JavaScript online

Ejercicios de esta lección Clases y objetos

Evalúa tus conocimientos de esta lección Clases y objetos con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.

Funciones flecha

JavaScript
Puzzle

Polimorfismo

JavaScript
Test

Array

JavaScript
Código

Transformación con map()

JavaScript
Test

Gestor de tareas con JavaScript

JavaScript
Proyecto

Manipulación DOM

JavaScript
Test

Funciones

JavaScript
Test

Funciones flecha

JavaScript
Código

Async / Await

JavaScript
Código

Creación y uso de variables

JavaScript
Test

Excepciones

JavaScript
Puzzle

Promises

JavaScript
Código

Funciones cierre (closure)

JavaScript
Test

Herencia

JavaScript
Puzzle

Herencia

JavaScript
Test

Estructuras de control

JavaScript
Código

Selección de elementos DOM

JavaScript
Test

Modificación de elementos DOM

JavaScript
Test

Filtrado con filter() y find()

JavaScript
Test

Funciones cierre (closure)

JavaScript
Puzzle

Funciones

JavaScript
Puzzle

Mapas con Map

JavaScript
Test

Reducción con reduce()

JavaScript
Test

Callbacks

JavaScript
Puzzle

Manipulación DOM

JavaScript
Puzzle

Promises

JavaScript
Test

Async / Await

JavaScript
Test

Eventos del DOM

JavaScript
Puzzle

Async / Await

JavaScript
Puzzle

Promises

JavaScript
Puzzle

Filtrado con filter() y find()

JavaScript
Código

Callbacks

JavaScript
Test

Creación de clases y objetos Restaurante

JavaScript
Código

Reducción con reduce()

JavaScript
Código

Filtrado con filter() y find()

JavaScript
Puzzle

Reducción con reduce()

JavaScript
Puzzle

Conjuntos con Set

JavaScript
Puzzle

Herencia de clases

JavaScript
Código

Eventos del DOM

JavaScript
Test

Clases y objetos

JavaScript
Puzzle

Modificación de elementos DOM

JavaScript
Puzzle

Mapas con Map

JavaScript
Puzzle

Introducción a JavaScript

JavaScript
Test

Funciones

JavaScript
Código

Tipos de datos

JavaScript
Test

Clases y objetos

JavaScript
Test

Array

JavaScript
Test

Conjuntos con Set

JavaScript
Test

Array

JavaScript
Puzzle

Encapsulación

JavaScript
Puzzle

Clases y objetos

JavaScript
Código

Uso de operadores

JavaScript
Puzzle

Uso de operadores

JavaScript
Test

Estructuras de control

JavaScript
Test

Excepciones

JavaScript
Test

Transformación con map()

JavaScript
Puzzle

Funciones flecha

JavaScript
Test

Selección de elementos DOM

JavaScript
Puzzle

Encapsulación

JavaScript
Test

Mapas con Map

JavaScript
Código

Creación y uso de variables

JavaScript
Puzzle

Polimorfismo

JavaScript
Puzzle

Tipos de datos

JavaScript
Puzzle

Estructuras de control

JavaScript
Puzzle

Todas las lecciones de JavaScript

Accede a todas las lecciones de JavaScript y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.

Accede GRATIS a JavaScript y certifícate

Certificados de superación de JavaScript

Supera todos los ejercicios de programación del curso de JavaScript y obtén certificados de superación para mejorar tu currículum y tu empleabilidad.

En esta lección

Objetivos de aprendizaje de esta lección

  1. Comprender el concepto de clases y objetos en JavaScript.
  2. Conocer la sintaxis para declarar una clase utilizando la palabra clave class.
  3. Aprender a definir un constructor dentro de una clase para inicializar propiedades del objeto.
  4. Entender cómo se definen los métodos dentro de una clase y cómo se invocan desde los objetos.
  5. Saber cómo crear objetos a partir de una clase utilizando la palabra clave new.
  6. Comprender la diferencia entre campos estáticos y no estáticos en las clases.
  7. Conocer la importancia de encapsulación en la programación orientada a objetos.
  8. Aprender a acceder a las propiedades y métodos de un objeto utilizando la notación de punto.
  9. Saber cómo cada objeto creado a partir de una clase es independiente y tiene su propio conjunto de propiedades y métodos.
  10. Comprender cómo los objetos permiten organizar el código de manera estructurada y modular en la programación.