Modificadores de acceso y encapsulación

Avanzado
TypeScript
TypeScript
Actualizado: 10/04/2025

¡Desbloquea el curso completo!

IA
Ejercicios
Certificado
Entrar

Modificadores public, private y protected

Los modificadores de acceso son una característica fundamental en TypeScript que nos permite controlar la visibilidad y accesibilidad de las propiedades y métodos de una clase. Estos modificadores son esenciales para implementar la encapsulación, uno de los pilares de la programación orientada a objetos.

TypeScript ofrece tres modificadores de acceso principales: public, private y protected. Cada uno define un nivel diferente de accesibilidad, permitiéndonos diseñar clases con interfaces bien definidas y ocultar los detalles de implementación.

Modificador public

El modificador public es el nivel de acceso predeterminado en TypeScript. Cuando una propiedad o método se declara como público, puede ser accedido desde cualquier parte del código, sin restricciones.

class User {
  public username: string;
  
  constructor(username: string) {
    this.username = username;
  }
  
  public displayInfo(): void {
    console.log(`Username: ${this.username}`);
  }
}

const user = new User("john_doe");
console.log(user.username); // Acceso permitido
user.displayInfo(); // Acceso permitido

En este ejemplo, tanto la propiedad username como el método displayInfo() son públicos, por lo que pueden ser accedidos directamente desde fuera de la clase. Al ser public el modificador predeterminado, podríamos omitirlo y el comportamiento sería el mismo.

Modificador private

El modificador private restringe el acceso a propiedades y métodos para que solo sean accesibles dentro de la misma clase donde se declaran. Esto es útil para ocultar detalles de implementación interna.

class BankAccount {
  private balance: number;
  private accountNumber: string;
  
  constructor(initialBalance: number, accountNumber: string) {
    this.balance = initialBalance;
    this.accountNumber = accountNumber;
  }
  
  public deposit(amount: number): void {
    if (amount > 0) {
      this.balance += amount;
      this.logTransaction("deposit", amount);
    }
  }
  
  public withdraw(amount: number): boolean {
    if (amount > 0 && this.balance >= amount) {
      this.balance -= amount;
      this.logTransaction("withdrawal", amount);
      return true;
    }
    return false;
  }
  
  public getBalance(): number {
    return this.balance;
  }
  
  private logTransaction(type: string, amount: number): void {
    console.log(`Transaction: ${type} of $${amount} from account ${this.accountNumber}`);
  }
}

const account = new BankAccount(1000, "ACC123456");

// Acceso a métodos públicos
account.deposit(500);
account.withdraw(200);
console.log(account.getBalance()); // 1300

// Estos accesos generarían errores de compilación:
// console.log(account.balance); // Error: 'balance' es privado
// console.log(account.accountNumber); // Error: 'accountNumber' es privado
// account.logTransaction("test", 100); // Error: 'logTransaction' es privado

En este ejemplo, balance y accountNumber son propiedades privadas, lo que significa que no se puede acceder a ellas directamente desde fuera de la clase. El método logTransaction() también es privado, por lo que solo puede ser llamado desde otros métodos dentro de la clase.

Modificador protected

El modificador protected es un punto intermedio entre public y private. Los miembros protected son accesibles dentro de la clase donde se declaran y en las clases que heredan de ella, pero no desde fuera.

class Person {
  protected name: string;
  protected age: number;
  
  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }
  
  protected getDetails(): string {
    return `${this.name}, ${this.age} years old`;
  }
}

class Employee extends Person {
  private department: string;
  
  constructor(name: string, age: number, department: string) {
    super(name, age);
    this.department = department;
  }
  
  public getEmployeeInfo(): string {
    // Podemos acceder a propiedades y métodos protected de la clase padre
    return `${this.getDetails()} - Department: ${this.department}`;
  }
  
  public updateName(newName: string): void {
    // Podemos modificar propiedades protected heredadas
    this.name = newName;
  }
}

const employee = new Employee("Alice", 30, "Engineering");
console.log(employee.getEmployeeInfo()); // "Alice, 30 years old - Department: Engineering"
employee.updateName("Alicia");
console.log(employee.getEmployeeInfo()); // "Alicia, 30 years old - Department: Engineering"

// Estos accesos generarían errores:
// console.log(employee.name); // Error: 'name' es protected
// console.log(employee.age); // Error: 'age' es protected
// employee.getDetails(); // Error: 'getDetails' es protected

En este ejemplo, name y age son propiedades protegidas en la clase Person. La clase Employee, que hereda de Person, puede acceder a estas propiedades y al método protegido getDetails(). Sin embargo, desde fuera de estas clases, no se puede acceder directamente a estos miembros protegidos.

Declaración abreviada en el constructor

TypeScript ofrece una sintaxis abreviada para declarar propiedades de clase y asignarlas a través del constructor en una sola línea:

class Product {
  constructor(
    public id: string,
    private name: string,
    private price: number,
    protected category: string
  ) {}
  
  public getProductInfo(): string {
    return `Product: ${this.name}, Price: $${this.price}, Category: ${this.category}`;
  }
}

const laptop = new Product("P001", "Laptop", 999.99, "Electronics");
console.log(laptop.getProductInfo()); // "Product: Laptop, Price: $999.99, Category: Electronics"
console.log(laptop.id); // "P001" - accesible porque es public

// Estos accesos generarían errores:
// console.log(laptop.name); // Error: 'name' es private
// console.log(laptop.price); // Error: 'price' es private
// console.log(laptop.category); // Error: 'category' es protected

Esta sintaxis elimina la necesidad de declarar las propiedades por separado y luego asignarlas en el constructor, lo que hace que el código sea más conciso y legible.

Aplicaciones prácticas de los modificadores de acceso

Los modificadores de acceso son fundamentales para implementar patrones de diseño y mantener un código robusto:

  • Seguridad de datos: Usar private para propiedades que almacenan datos sensibles evita modificaciones accidentales.
class UserCredentials {
  constructor(
    public username: string,
    private password: string
  ) {}
  
  public authenticate(inputPassword: string): boolean {
    return this.password === inputPassword;
  }
  
  // No hay método para obtener la contraseña directamente
}

const credentials = new UserCredentials("admin", "secret123");
console.log(credentials.authenticate("secret123")); // true
// console.log(credentials.password); // Error: 'password' es private
  • API pública clara: Marcar como public solo lo que debe ser parte de la API pública de la clase.
class DataProcessor {
  constructor(private data: number[]) {}
  
  public process(): number {
    return this.calculateAverage() * this.getNormalizationFactor();
  }
  
  private calculateAverage(): number {
    const sum = this.data.reduce((acc, val) => acc + val, 0);
    return sum / this.data.length;
  }
  
  private getNormalizationFactor(): number {
    return this.data.length > 10 ? 1.5 : 1;
  }
}

const processor = new DataProcessor([1, 2, 3, 4, 5]);
console.log(processor.process()); // API pública clara
// processor.calculateAverage(); // Error: método privado
  • Jerarquías de clases: Usar protected para compartir funcionalidad entre clases relacionadas sin exponerla públicamente.
class Shape {
  protected calculateArea(): number {
    return 0; // Implementación base
  }
  
  public getDescription(): string {
    return `This shape has an area of ${this.calculateArea()} square units.`;
  }
}

class Circle extends Shape {
  constructor(private radius: number) {
    super();
  }
  
  protected calculateArea(): number {
    return Math.PI * this.radius * this.radius;
  }
}

const circle = new Circle(5);
console.log(circle.getDescription()); // "This shape has an area of 78.54... square units."
// circle.calculateArea(); // Error: método protegido

Los modificadores de acceso son herramientas poderosas para estructurar nuestro código de manera que refleje claramente nuestras intenciones de diseño, proteja los datos y facilite el mantenimiento a largo plazo.

¿Te está gustando esta lección?

Inicia sesión para no perder tu progreso y accede a miles de tutoriales, ejercicios prácticos y nuestro asistente de IA.

Progreso guardado
Asistente IA
Ejercicios
Iniciar sesión gratis

Más de 25.000 desarrolladores ya confían en CertiDevs

Principios de encapsulación en la práctica

La encapsulación es uno de los pilares fundamentales de la programación orientada a objetos que permite ocultar los detalles internos de implementación de una clase y exponer solo lo necesario. En TypeScript, implementamos la encapsulación principalmente a través de los modificadores de acceso, pero el concepto va más allá de simplemente marcar propiedades como privadas.

Una buena encapsulación nos permite crear abstracciones robustas que son más fáciles de mantener, extender y utilizar correctamente. Veamos cómo aplicar estos principios en situaciones prácticas.

Ocultamiento de la implementación interna

El primer principio de la encapsulación consiste en ocultar los detalles de implementación que no necesitan ser expuestos:

class ShoppingCart {
  private items: Array<{product: string, price: number, quantity: number}> = [];
  private discountCode: string | null = null;
  
  public addItem(product: string, price: number, quantity: number = 1): void {
    this.items.push({ product, price, quantity });
  }
  
  public removeItem(index: number): void {
    if (index >= 0 && index < this.items.length) {
      this.items.splice(index, 1);
    }
  }
  
  public applyDiscount(code: string): void {
    this.discountCode = code;
  }
  
  public getTotalPrice(): number {
    const subtotal = this.calculateSubtotal();
    return this.applyDiscountToTotal(subtotal);
  }
  
  private calculateSubtotal(): number {
    return this.items.reduce((total, item) => 
      total + (item.price * item.quantity), 0);
  }
  
  private applyDiscountToTotal(amount: number): number {
    if (this.discountCode === "SAVE10") {
      return amount * 0.9; // 10% discount
    }
    return amount;
  }
}

En este ejemplo, el usuario del carrito de compras no necesita saber cómo se calculan los precios internamente o cómo se almacenan los productos. Solo necesita una interfaz clara para agregar/eliminar productos y obtener el precio total.

Validación de datos de entrada

La encapsulación nos permite validar los datos antes de modificar el estado interno:

class BankAccount {
  private _balance: number = 0;
  private _accountNumber: string;
  private _dailyWithdrawalLimit: number = 1000;
  private _withdrawalsToday: number = 0;
  
  constructor(accountNumber: string, initialBalance: number = 0) {
    if (accountNumber.length !== 10) {
      throw new Error("Account number must be 10 characters long");
    }
    
    if (initialBalance < 0) {
      throw new Error("Initial balance cannot be negative");
    }
    
    this._accountNumber = accountNumber;
    this._balance = initialBalance;
  }
  
  public deposit(amount: number): boolean {
    if (amount <= 0) {
      console.error("Deposit amount must be positive");
      return false;
    }
    
    this._balance += amount;
    return true;
  }
  
  public withdraw(amount: number): boolean {
    if (amount <= 0) {
      console.error("Withdrawal amount must be positive");
      return false;
    }
    
    if (amount > this._balance) {
      console.error("Insufficient funds");
      return false;
    }
    
    if (this._withdrawalsToday >= 3) {
      console.error("Daily withdrawal limit reached");
      return false;
    }
    
    if (amount > this._dailyWithdrawalLimit) {
      console.error(`Cannot withdraw more than ${this._dailyWithdrawalLimit} per day`);
      return false;
    }
    
    this._balance -= amount;
    this._withdrawalsToday++;
    return true;
  }
  
  public getBalance(): number {
    return this._balance;
  }
}

Aquí, la encapsulación nos permite implementar reglas de negocio complejas (límites de retiro, validación de números de cuenta) sin exponer estos detalles al usuario de la clase.

Mantenimiento del estado consistente

La encapsulación nos ayuda a mantener el estado interno consistente, asegurando que las propiedades relacionadas siempre estén sincronizadas:

class Rectangle {
  private _width: number;
  private _height: number;
  private _area: number;
  private _perimeter: number;
  
  constructor(width: number, height: number) {
    this._width = width;
    this._height = height;
    this.recalculate();
  }
  
  private recalculate(): void {
    this._area = this._width * this._height;
    this._perimeter = 2 * (this._width + this._height);
  }
  
  public get width(): number {
    return this._width;
  }
  
  public set width(value: number) {
    if (value <= 0) {
      throw new Error("Width must be positive");
    }
    this._width = value;
    this.recalculate();
  }
  
  public get height(): number {
    return this._height;
  }
  
  public set height(value: number) {
    if (value <= 0) {
      throw new Error("Height must be positive");
    }
    this._height = value;
    this.recalculate();
  }
  
  public get area(): number {
    return this._area;
  }
  
  public get perimeter(): number {
    return this._perimeter;
  }
}

En este ejemplo, el área y el perímetro se recalculan automáticamente cuando cambia el ancho o el alto, manteniendo el estado interno consistente sin que el usuario tenga que preocuparse por ello.

Abstracción de complejidad

La encapsulación nos permite ocultar operaciones complejas detrás de interfaces simples:

class ImageProcessor {
  private image: number[][]; // Representación matricial de la imagen
  
  constructor(imageData: number[][]) {
    this.image = imageData;
  }
  
  public applyFilter(filterName: string): void {
    switch (filterName) {
      case "blur":
        this.applyBlurFilter();
        break;
      case "sharpen":
        this.applySharpenFilter();
        break;
      case "grayscale":
        this.applyGrayscaleFilter();
        break;
      default:
        throw new Error(`Unknown filter: ${filterName}`);
    }
  }
  
  private applyBlurFilter(): void {
    // Implementación compleja del algoritmo de desenfoque
    console.log("Applying blur filter...");
    // Código para aplicar el filtro...
  }
  
  private applySharpenFilter(): void {
    // Implementación compleja del algoritmo de nitidez
    console.log("Applying sharpen filter...");
    // Código para aplicar el filtro...
  }
  
  private applyGrayscaleFilter(): void {
    // Implementación compleja del algoritmo de escala de grises
    console.log("Applying grayscale filter...");
    // Código para aplicar el filtro...
  }
  
  public getProcessedImage(): number[][] {
    return this.image;
  }
}

El usuario solo necesita conocer los nombres de los filtros disponibles, sin preocuparse por los complejos algoritmos de procesamiento de imágenes que se ejecutan internamente.

Cambios de implementación sin afectar la interfaz

La encapsulación nos permite cambiar la implementación interna sin afectar el código que utiliza nuestra clase:

class UserAuthentication {
  private users: Map<string, { passwordHash: string, salt: string }>;
  
  constructor() {
    this.users = new Map();
  }
  
  public registerUser(username: string, password: string): boolean {
    if (this.users.has(username)) {
      return false;
    }
    
    // Versión 1: Almacenamiento simple (podría cambiar en el futuro)
    const salt = this.generateSalt();
    const passwordHash = this.hashPassword(password, salt);
    this.users.set(username, { passwordHash, salt });
    return true;
  }
  
  public authenticateUser(username: string, password: string): boolean {
    const user = this.users.get(username);
    if (!user) {
      return false;
    }
    
    const hashedInput = this.hashPassword(password, user.salt);
    return hashedInput === user.passwordHash;
  }
  
  private generateSalt(): string {
    // En una implementación real, generaríamos un salt aleatorio
    return Math.random().toString(36).substring(2, 15);
  }
  
  private hashPassword(password: string, salt: string): string {
    // En una implementación real, usaríamos un algoritmo de hash seguro
    // Esta es solo una simulación simple
    return `${password}:${salt}`;
  }
}

Si más adelante decidimos cambiar el algoritmo de hash o la estructura de almacenamiento de usuarios, podemos hacerlo sin afectar el código que utiliza esta clase, siempre que mantengamos la misma interfaz pública.

Patrones de diseño basados en encapsulación

La encapsulación es la base de muchos patrones de diseño. Por ejemplo, el patrón Singleton garantiza que una clase tenga una única instancia y proporciona un punto de acceso global a ella:

class DatabaseConnection {
  private static instance: DatabaseConnection | null = null;
  private connectionString: string;
  private isConnected: boolean = false;
  
  private constructor(connectionString: string) {
    this.connectionString = connectionString;
  }
  
  public static getInstance(connectionString: string): DatabaseConnection {
    if (!DatabaseConnection.instance) {
      DatabaseConnection.instance = new DatabaseConnection(connectionString);
    }
    return DatabaseConnection.instance;
  }
  
  public connect(): boolean {
    if (this.isConnected) {
      return true;
    }
    
    console.log(`Connecting to database with: ${this.connectionString}`);
    // Lógica de conexión real aquí...
    this.isConnected = true;
    return true;
  }
  
  public query(sql: string): any[] {
    if (!this.isConnected) {
      throw new Error("Must connect to database before querying");
    }
    
    console.log(`Executing query: ${sql}`);
    // Lógica de consulta real aquí...
    return [];
  }
  
  public disconnect(): void {
    if (this.isConnected) {
      console.log("Disconnecting from database");
      this.isConnected = false;
    }
  }
}

// Uso del singleton
const db1 = DatabaseConnection.getInstance("server=localhost;user=root");
const db2 = DatabaseConnection.getInstance("server=localhost;user=root");

console.log(db1 === db2); // true - ambas variables referencian la misma instancia

db1.connect();
db1.query("SELECT * FROM users");

En este ejemplo, el constructor privado y el método estático getInstance() encapsulan la lógica de creación de instancias, asegurando que solo exista una conexión a la base de datos.

Beneficios prácticos de la encapsulación

  • Reducción de dependencias: Al ocultar los detalles de implementación, reducimos el acoplamiento entre componentes.

  • Código más mantenible: Los cambios internos no afectan al código que utiliza la clase.

  • Mayor seguridad: Controlamos cómo se accede y modifica el estado interno.

  • Mejor testabilidad: Podemos probar la interfaz pública sin preocuparnos por los detalles internos.

  • Evolución del código: Podemos mejorar la implementación interna sin romper el código existente.

La encapsulación no es solo una regla técnica, sino un principio de diseño que nos ayuda a crear código más robusto, flexible y fácil de mantener a largo plazo. Aplicando estos principios en la práctica, podemos construir sistemas complejos a partir de componentes bien encapsulados que funcionan juntos de manera predecible y confiable.

Patrón de acceso mediante getters y setters

Los getters y setters son métodos especiales que proporcionan una forma controlada de acceder y modificar las propiedades de una clase. Este patrón es fundamental para implementar la encapsulación en TypeScript, permitiéndonos ocultar los detalles internos de implementación mientras exponemos una interfaz clara para interactuar con los objetos.

TypeScript ofrece dos formas principales de implementar getters y setters: la sintaxis tradicional de métodos y la sintaxis de accesores de propiedades. Ambas cumplen el mismo propósito, pero con diferentes estilos de código y casos de uso.

Implementación básica con métodos tradicionales

La forma más directa de implementar getters y setters es mediante métodos explícitos:

class Person {
  private _firstName: string;
  private _lastName: string;
  private _age: number;

  constructor(firstName: string, lastName: string, age: number) {
    this._firstName = firstName;
    this._lastName = lastName;
    this._age = age;
  }

  // Getters
  public getFirstName(): string {
    return this._firstName;
  }

  public getLastName(): string {
    return this._lastName;
  }

  public getAge(): number {
    return this._age;
  }

  // Setters
  public setFirstName(firstName: string): void {
    this._firstName = firstName;
  }

  public setLastName(lastName: string): void {
    this._lastName = lastName;
  }

  public setAge(age: number): void {
    if (age < 0 || age > 120) {
      throw new Error("Age must be between 0 and 120");
    }
    this._age = age;
  }

  // Método adicional que utiliza propiedades privadas
  public getFullName(): string {
    return `${this._firstName} ${this._lastName}`;
  }
}

const person = new Person("John", "Doe", 30);
console.log(person.getFullName()); // "John Doe"
person.setAge(31);
console.log(person.getAge()); // 31

En este ejemplo, las propiedades _firstName, _lastName y _age son privadas (indicado por el prefijo _ y el modificador private). Para acceder a ellas desde fuera de la clase, utilizamos métodos getter y setter específicos.

Sintaxis de accesores de propiedades

TypeScript también admite una sintaxis más moderna y concisa para getters y setters, utilizando las palabras clave get y set:

class Product {
  private _name: string;
  private _price: number;
  private _stock: number;

  constructor(name: string, price: number, stock: number) {
    this._name = name;
    this._price = price;
    this._stock = stock;
  }

  // Getter para name
  get name(): string {
    return this._name;
  }

  // Setter para name
  set name(value: string) {
    if (value.trim() === "") {
      throw new Error("Product name cannot be empty");
    }
    this._name = value;
  }

  // Getter para price
  get price(): number {
    return this._price;
  }

  // Setter para price
  set price(value: number) {
    if (value < 0) {
      throw new Error("Price cannot be negative");
    }
    this._price = value;
  }

  // Getter para stock
  get stock(): number {
    return this._stock;
  }

  // Setter para stock
  set stock(value: number) {
    if (!Number.isInteger(value) || value < 0) {
      throw new Error("Stock must be a non-negative integer");
    }
    this._stock = value;
  }

  // Propiedad calculada (solo getter)
  get isAvailable(): boolean {
    return this._stock > 0;
  }
}

const laptop = new Product("Laptop", 999.99, 5);

// Uso de getters
console.log(laptop.name); // "Laptop"
console.log(laptop.price); // 999.99
console.log(laptop.isAvailable); // true

// Uso de setters
laptop.price = 899.99;
laptop.stock = 3;

// Validación en acción
try {
  laptop.price = -50; // Lanzará un error
} catch (error) {
  console.error(error.message); // "Price cannot be negative"
}

Con esta sintaxis, los getters y setters se utilizan como si fueran propiedades normales, lo que hace que el código sea más limpio y natural. Internamente, sin embargo, se están ejecutando los métodos definidos.

Ventajas de usar getters y setters

  • 1. Validación de datos: Podemos validar los valores antes de asignarlos a las propiedades.
class Temperature {
  private _celsius: number;

  constructor(celsius: number) {
    this.celsius = celsius; // Usa el setter para validación
  }

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

  set celsius(value: number) {
    if (value < -273.15) {
      throw new Error("Temperature cannot be below absolute zero (-273.15°C)");
    }
    this._celsius = value;
  }

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

  set fahrenheit(value: number) {
    // Convertir Fahrenheit a Celsius y usar el setter de celsius para validación
    this.celsius = (value - 32) * 5/9;
  }
}

const temp = new Temperature(25);
console.log(temp.fahrenheit); // 77

temp.celsius = 100;
console.log(temp.fahrenheit); // 212

temp.fahrenheit = 68;
console.log(temp.celsius); // 20
  • 2. Propiedades calculadas: Podemos crear propiedades que se calculan dinámicamente.
class Circle {
  private _radius: number;

  constructor(radius: number) {
    this._radius = radius;
  }

  get radius(): number {
    return this._radius;
  }

  set radius(value: number) {
    if (value <= 0) {
      throw new Error("Radius must be positive");
    }
    this._radius = value;
  }

  get diameter(): number {
    return this._radius * 2;
  }

  get area(): number {
    return Math.PI * this._radius * this._radius;
  }

  get circumference(): number {
    return 2 * Math.PI * this._radius;
  }
}

const circle = new Circle(5);
console.log(circle.area); // ~78.54
console.log(circle.circumference); // ~31.42

circle.radius = 10;
console.log(circle.diameter); // 20
  • 3. Control de acceso asimétrico: Podemos permitir leer una propiedad pero no modificarla (o viceversa).
class User {
  private _username: string;
  private _createdAt: Date;
  private _password: string;

  constructor(username: string, password: string) {
    this._username = username;
    this._password = this.hashPassword(password);
    this._createdAt = new Date();
  }

  // Getter sin setter - propiedad de solo lectura
  get username(): string {
    return this._username;
  }

  // Getter sin setter - propiedad de solo lectura
  get createdAt(): Date {
    return new Date(this._createdAt.getTime()); // Devuelve una copia para evitar modificaciones
  }

  // Solo setter sin getter - no se puede leer directamente
  set password(newPassword: string) {
    if (newPassword.length < 8) {
      throw new Error("Password must be at least 8 characters long");
    }
    this._password = this.hashPassword(newPassword);
  }

  // Método para verificar la contraseña
  verifyPassword(password: string): boolean {
    return this.hashPassword(password) === this._password;
  }

  private hashPassword(password: string): string {
    // En una aplicación real, usaríamos un algoritmo de hash seguro
    return `hashed_${password}`;
  }
}

const user = new User("john_doe", "securepass123");

console.log(user.username); // "john_doe"
console.log(user.createdAt); // Date object

// No podemos hacer esto:
// user.username = "new_username"; // Error: solo lectura
// console.log(user.password); // Error: no hay getter

// Cambiar contraseña
user.password = "newSecurePass456";
console.log(user.verifyPassword("newSecurePass456")); // true
  • 4. Lógica de cambio de estado: Podemos ejecutar lógica adicional cuando una propiedad cambia.
class TodoItem {
  private _title: string;
  private _completed: boolean;
  private _completedAt: Date | null;

  constructor(title: string) {
    this._title = title;
    this._completed = false;
    this._completedAt = null;
  }

  get title(): string {
    return this._title;
  }

  set title(value: string) {
    if (value.trim() === "") {
      throw new Error("Title cannot be empty");
    }
    this._title = value;
  }

  get completed(): boolean {
    return this._completed;
  }

  set completed(value: boolean) {
    // Si cambiamos de incompleto a completo
    if (value === true && this._completed === false) {
      this._completedAt = new Date();
    }
    
    // Si cambiamos de completo a incompleto
    if (value === false && this._completed === true) {
      this._completedAt = null;
    }
    
    this._completed = value;
  }

  get completedAt(): Date | null {
    return this._completedAt;
  }
}

const task = new TodoItem("Learn TypeScript");
console.log(task.completed); // false
console.log(task.completedAt); // null

task.completed = true;
console.log(task.completedAt); // Date object

task.completed = false;
console.log(task.completedAt); // null

Patrones avanzados con getters y setters

  • Propiedades con memoria (memoization): Podemos calcular un valor solo cuando sea necesario y almacenarlo en caché.
class DataProcessor {
  private _data: number[];
  private _sortedData: number[] | null = null;
  private _statistics: { mean: number; median: number } | null = null;

  constructor(data: number[]) {
    this._data = [...data]; // Copia para evitar modificaciones externas
  }

  get data(): number[] {
    return [...this._data]; // Devuelve una copia para evitar modificaciones
  }

  set data(newData: number[]) {
    this._data = [...newData];
    // Invalidar caché cuando los datos cambian
    this._sortedData = null;
    this._statistics = null;
  }

  get sortedData(): number[] {
    // Calcular solo si es necesario
    if (this._sortedData === null) {
      this._sortedData = [...this._data].sort((a, b) => a - b);
    }
    return [...this._sortedData];
  }

  get statistics(): { mean: number; median: number } {
    // Calcular solo si es necesario
    if (this._statistics === null) {
      const mean = this._data.reduce((sum, val) => sum + val, 0) / this._data.length;
      
      // Para la mediana, necesitamos los datos ordenados
      const sorted = this.sortedData; // Usa el getter que implementa memoization
      const mid = Math.floor(sorted.length / 2);
      const median = sorted.length % 2 === 0
        ? (sorted[mid - 1] + sorted[mid]) / 2
        : sorted[mid];
      
      this._statistics = { mean, median };
    }
    return { ...this._statistics };
  }
}

const processor = new DataProcessor([5, 1, 9, 3, 7]);
console.log(processor.statistics); // Calcula y almacena en caché
console.log(processor.statistics); // Usa el valor en caché

processor.data = [10, 20, 30, 40];
console.log(processor.statistics); // Recalcula porque los datos cambiaron
  • Propiedades proxy: Podemos usar getters y setters para acceder a propiedades de objetos anidados.
class UserProfile {
  private _user: {
    id: number;
    personal: {
      firstName: string;
      lastName: string;
    };
    contact: {
      email: string;
      phone: string | null;
    };
  };

  constructor(
    id: number,
    firstName: string,
    lastName: string,
    email: string,
    phone: string | null = null
  ) {
    this._user = {
      id,
      personal: { firstName, lastName },
      contact: { email, phone }
    };
  }

  // Getters y setters para propiedades anidadas
  get firstName(): string {
    return this._user.personal.firstName;
  }

  set firstName(value: string) {
    this._user.personal.firstName = value;
  }

  get lastName(): string {
    return this._user.personal.lastName;
  }

  set lastName(value: string) {
    this._user.personal.lastName = value;
  }

  get email(): string {
    return this._user.contact.email;
  }

  set email(value: string) {
    if (!value.includes('@')) {
      throw new Error("Invalid email format");
    }
    this._user.contact.email = value;
  }

  get phone(): string | null {
    return this._user.contact.phone;
  }

  set phone(value: string | null) {
    this._user.contact.phone = value;
  }

  // Propiedad calculada
  get fullName(): string {
    return `${this.firstName} ${this.lastName}`;
  }

  // Método para obtener todos los datos
  getData(): any {
    return { ...this._user }; // Devuelve una copia
  }
}

const profile = new UserProfile(1, "Jane", "Smith", "jane@example.com");
console.log(profile.fullName); // "Jane Smith"

profile.lastName = "Johnson";
profile.email = "jane.johnson@example.com";

console.log(profile.getData());

Mejores prácticas para getters y setters

  • 1. Convenciones de nomenclatura: Usa un prefijo de guion bajo para propiedades privadas.
// Buena práctica
private _count: number;
get count(): number { return this._count; }

// Evitar
private count: number; // Confuso con el getter
  • 2. Operaciones ligeras en getters: Los getters deben ser rápidos y no tener efectos secundarios.
// Buena práctica
get fullName(): string {
  return `${this._firstName} ${this._lastName}`;
}

// Evitar en getters
get userData(): any {
  // Evitar operaciones costosas o con efectos secundarios en getters
  return this.fetchUserDataFromServer(); // ❌ Mal uso
}
  • 3. Validación en setters: Aprovecha los setters para validar datos.
set email(value: string) {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(value)) {
    throw new Error("Invalid email format");
  }
  this._email = value;
}
  • 4. Inmutabilidad: Devuelve copias de objetos y arrays para evitar modificaciones no controladas.
get items(): string[] {
  return [...this._items]; // Devuelve una copia
}

get config(): object {
  return { ...this._config }; // Copia superficial
}
  • 5. Consistencia: Si una propiedad tiene getter, considera si también necesita un setter.
// Propiedad de solo lectura (tiene sentido)
get createdAt(): Date {
  return new Date(this._createdAt.getTime());
}

// Propiedad calculada (tiene sentido que sea solo lectura)
get totalPrice(): number {
  return this._items.reduce((sum, item) => sum + item.price, 0);
}

Los getters y setters son una herramienta poderosa para implementar la encapsulación en TypeScript. Permiten controlar el acceso a las propiedades de una clase, validar datos, implementar propiedades calculadas y mantener el estado interno consistente. Al utilizarlos correctamente, podemos crear interfaces de clase limpias y robustas que ocultan la complejidad interna mientras proporcionan una API intuitiva para los usuarios de nuestras clases.

Propiedades y métodos estáticos

Las propiedades y métodos estáticos son miembros de clase que pertenecen a la clase misma y no a las instancias individuales. Esto significa que puedes acceder a ellos directamente desde la clase sin necesidad de crear un objeto. Esta característica es especialmente útil para funcionalidades que son independientes del estado de instancias específicas.

En TypeScript, definimos miembros estáticos utilizando la palabra clave static. Veamos cómo funcionan y cuáles son sus aplicaciones prácticas.

Definición básica de miembros estáticos

Para declarar una propiedad o método como estático, simplemente añadimos la palabra clave static antes de su definición:

class MathOperations {
  // Propiedad estática
  static PI: number = 3.14159265359;
  
  // Método estático
  static square(num: number): number {
    return num * num;
  }
  
  static cube(num: number): number {
    return num * num * num;
  }
}

// Acceso a miembros estáticos sin crear instancias
console.log(MathOperations.PI); // 3.14159265359
console.log(MathOperations.square(5)); // 25
console.log(MathOperations.cube(3)); // 27

En este ejemplo, tanto PI como los métodos square y cube son estáticos. Podemos acceder a ellos directamente a través de la clase MathOperations sin necesidad de crear una instancia.

Diferencias entre miembros estáticos y de instancia

Es importante entender la diferencia entre miembros estáticos y miembros de instancia:

class Counter {
  // Propiedad estática - compartida entre todas las instancias
  static totalCount: number = 0;
  
  // Propiedad de instancia - única para cada instancia
  instanceCount: number = 0;
  
  constructor() {
    // Incrementamos ambos contadores al crear una instancia
    Counter.totalCount++;
    this.instanceCount++;
  }
  
  // Método de instancia
  increment(): void {
    Counter.totalCount++;
    this.instanceCount++;
  }
  
  // Método estático
  static getTotalCount(): number {
    return Counter.totalCount;
  }
}

const counter1 = new Counter(); // totalCount = 1, instanceCount = 1
const counter2 = new Counter(); // totalCount = 2, instanceCount = 1

counter1.increment(); // totalCount = 3, instanceCount de counter1 = 2
counter2.increment(); // totalCount = 4, instanceCount de counter2 = 2

console.log(Counter.totalCount); // 4
console.log(counter1.instanceCount); // 2
console.log(counter2.instanceCount); // 2
console.log(Counter.getTotalCount()); // 4

En este ejemplo:

  • totalCount es una propiedad estática compartida por todas las instancias de Counter
  • instanceCount es una propiedad de instancia única para cada objeto
  • Cuando incrementamos el contador en una instancia, afecta a su propio instanceCount y al totalCount compartido

Acceso a miembros estáticos desde dentro de la clase

Dentro de los métodos de clase, podemos acceder a los miembros estáticos de dos formas:

class Configuration {
  static apiUrl: string = "https://api.example.com";
  static timeout: number = 3000;
  static version: string = "1.0.0";
  
  // Método de instancia que accede a propiedades estáticas
  getApiConfig(): object {
    // Usando el nombre de la clase
    return {
      url: Configuration.apiUrl,
      timeout: Configuration.timeout,
      version: Configuration.version
    };
  }
  
  // Método estático que accede a otras propiedades estáticas
  static getFullUrl(endpoint: string): string {
    // Usando this en contexto estático
    return `${this.apiUrl}/${endpoint}?version=${this.version}`;
  }
}

const config = new Configuration();
console.log(config.getApiConfig());
console.log(Configuration.getFullUrl("users"));

Observa que:

  • En métodos de instancia, debemos usar el nombre de la clase para acceder a miembros estáticos
  • En métodos estáticos, podemos usar this para referir a otros miembros estáticos

Casos de uso comunes para miembros estáticos

1. Constantes y configuración

Las propiedades estáticas son ideales para definir constantes o valores de configuración compartidos:

class AppSettings {
  static readonly API_URL: string = "https://api.myapp.com/v1";
  static readonly MAX_LOGIN_ATTEMPTS: number = 3;
  static readonly DEFAULT_TIMEOUT: number = 5000;
  static readonly SUPPORTED_LANGUAGES: string[] = ["en", "es", "fr", "de"];
  
  // Podemos tener métodos estáticos para acceder a configuración
  static isLanguageSupported(lang: string): boolean {
    return AppSettings.SUPPORTED_LANGUAGES.includes(lang);
  }
}

// Uso en la aplicación
if (AppSettings.isLanguageSupported("ja")) {
  console.log("Japanese is supported");
} else {
  console.log("Japanese is not supported");
}

const timeout = AppSettings.DEFAULT_TIMEOUT;

El uso de readonly garantiza que estas constantes no puedan ser modificadas accidentalmente.

2. Métodos de utilidad

Los métodos estáticos son perfectos para funciones de utilidad que no dependen del estado de la instancia:

class StringUtils {
  static capitalize(str: string): string {
    if (!str) return str;
    return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
  }
  
  static truncate(str: string, maxLength: number): string {
    if (str.length <= maxLength) return str;
    return str.slice(0, maxLength) + "...";
  }
  
  static slugify(str: string): string {
    return str
      .toLowerCase()
      .replace(/\s+/g, "-")
      .replace(/[^\w\-]+/g, "");
  }
}

// Uso
const title = "hello world";
console.log(StringUtils.capitalize(title)); // "Hello world"
console.log(StringUtils.truncate("This is a long text", 10)); // "This is a..."
console.log(StringUtils.slugify("Hello World!")); // "hello-world"

3. Fábricas y métodos de creación

Los métodos estáticos son útiles para implementar patrones de fábrica que crean instancias de una clase:

class User {
  private constructor(
    public readonly id: string,
    public readonly name: string,
    public readonly email: string,
    private readonly createdAt: Date
  ) {}
  
  // Método de fábrica para crear un nuevo usuario
  static createNew(name: string, email: string): User {
    const id = `user_${Date.now()}`;
    return new User(id, name, email, new Date());
  }
  
  // Método de fábrica para crear desde datos existentes
  static fromDatabase(data: any): User {
    return new User(
      data.id,
      data.name,
      data.email,
      new Date(data.created_at)
    );
  }
  
  // Método de fábrica para usuario invitado
  static createGuest(): User {
    return new User(
      `guest_${Date.now()}`,
      "Guest User",
      "guest@example.com",
      new Date()
    );
  }
}

// Uso de los métodos de fábrica
const newUser = User.createNew("Alice", "alice@example.com");
const guestUser = User.createGuest();

// Simulando datos de base de datos
const dbData = {
  id: "user_123",
  name: "Bob",
  email: "bob@example.com",
  created_at: "2023-01-15T10:30:00Z"
};
const existingUser = User.fromDatabase(dbData);

En este ejemplo, el constructor es privado, forzando el uso de los métodos de fábrica estáticos para crear instancias.

4. Singleton y estado compartido

Los miembros estáticos permiten implementar el patrón Singleton y mantener estado compartido:

class Logger {
  private static instance: Logger | null = null;
  private logs: string[] = [];
  
  private constructor() {
    // Constructor privado para evitar instanciación directa
  }
  
  static getInstance(): Logger {
    if (!Logger.instance) {
      Logger.instance = new Logger();
    }
    return Logger.instance;
  }
  
  log(message: string): void {
    const timestamp = new Date().toISOString();
    this.logs.push(`[${timestamp}] ${message}`);
    console.log(`[LOG] ${message}`);
  }
  
  error(message: string): void {
    const timestamp = new Date().toISOString();
    this.logs.push(`[${timestamp}] ERROR: ${message}`);
    console.error(`[ERROR] ${message}`);
  }
  
  getLogs(): string[] {
    return [...this.logs];
  }
}

// Uso del singleton
const logger1 = Logger.getInstance();
const logger2 = Logger.getInstance();

logger1.log("Application started");
logger2.error("Failed to connect to database");

console.log(logger1 === logger2); // true - misma instancia
console.log(logger1.getLogs()); // Muestra todos los logs

5. Contadores y estadísticas

Las propiedades estáticas son útiles para llevar estadísticas globales:

class RequestTracker {
  static totalRequests: number = 0;
  static successfulRequests: number = 0;
  static failedRequests: number = 0;
  
  static trackRequest(success: boolean): void {
    RequestTracker.totalRequests++;
    if (success) {
      RequestTracker.successfulRequests++;
    } else {
      RequestTracker.failedRequests++;
    }
  }
  
  static get successRate(): number {
    if (this.totalRequests === 0) return 0;
    return this.successfulRequests / this.totalRequests;
  }
  
  static getStats(): object {
    return {
      total: this.totalRequests,
      successful: this.successfulRequests,
      failed: this.failedRequests,
      successRate: this.successRate
    };
  }
}

// Simulando algunas peticiones
RequestTracker.trackRequest(true);  // éxito
RequestTracker.trackRequest(true);  // éxito
RequestTracker.trackRequest(false); // fallo
RequestTracker.trackRequest(true);  // éxito

console.log(RequestTracker.getStats());
// { total: 4, successful: 3, failed: 1, successRate: 0.75 }

Inicialización de propiedades estáticas

Las propiedades estáticas se inicializan cuando se carga la clase, no cuando se crean instancias:

class StaticInitExample {
  // Inicialización simple
  static counter: number = 0;
  
  // Inicialización con expresión
  static readonly CREATED_AT: Date = new Date();
  
  // Inicialización con función
  static readonly CONFIG: object = StaticInitExample.loadConfig();
  
  private static loadConfig(): object {
    console.log("Loading configuration...");
    // Simulando carga de configuración
    return {
      environment: "development",
      debug: true
    };
  }
  
  constructor() {
    StaticInitExample.counter++;
    console.log(`Instance created. Total: ${StaticInitExample.counter}`);
  }
}

console.log("Before class usage");
console.log(`Creation timestamp: ${StaticInitExample.CREATED_AT}`);
console.log(`Configuration: ${JSON.stringify(StaticInitExample.CONFIG)}`);

// Creando instancias
const instance1 = new StaticInitExample();
const instance2 = new StaticInitExample();

La salida mostrará que la configuración se carga antes de crear cualquier instancia.

Herencia y miembros estáticos

Los miembros estáticos se heredan, pero mantienen su contexto de clase:

class BaseService {
  static serviceName: string = "BaseService";
  
  static getServiceInfo(): string {
    return `Service: ${this.serviceName}`;
  }
  
  static logService(): void {
    console.log(`[LOG] ${this.getServiceInfo()}`);
  }
}

class UserService extends BaseService {
  static override serviceName: string = "UserService";
  
  static override getServiceInfo(): string {
    return `${super.getServiceInfo()} (User Management)`;
  }
}

BaseService.logService(); // [LOG] Service: BaseService
UserService.logService(); // [LOG] Service: UserService (User Management)

Observa que:

  • this en métodos estáticos se refiere a la clase actual
  • Podemos usar super para acceder a implementaciones de la clase base
  • Las propiedades estáticas se pueden sobrescribir en clases derivadas

Limitaciones y consideraciones

  • 1. No pueden acceder a miembros de instancia: Los métodos estáticos no pueden acceder directamente a propiedades o métodos de instancia.
class Example {
  instanceValue: number = 42;
  
  static staticMethod(): void {
    // Error: no se puede acceder a 'this.instanceValue' desde un contexto estático
    // console.log(this.instanceValue);
  }
}
  • 2. No pueden usar this como referencia a instancia: En métodos estáticos, this se refiere a la clase, no a una instancia.
class StaticThis {
  static className: string = "StaticThis";
  
  static printThis(): void {
    console.log(this); // Se refiere a la clase StaticThis, no a una instancia
    console.log(this.className); // Accede a otra propiedad estática
  }
}

StaticThis.printThis();
  • 3. Cuidado con la mutabilidad: Las propiedades estáticas mutables son compartidas entre todas las instancias.
class SharedState {
  static sharedArray: number[] = [];
  
  addToShared(value: number): void {
    SharedState.sharedArray.push(value);
  }
}

const obj1 = new SharedState();
const obj2 = new SharedState();

obj1.addToShared(1);
obj2.addToShared(2);

console.log(SharedState.sharedArray); // [1, 2]

Mejores prácticas para usar miembros estáticos

  • Usa propiedades estáticas para constantes de clase: Valores que son relevantes para toda la clase.
class HttpStatus {
  static readonly OK: number = 200;
  static readonly CREATED: number = 201;
  static readonly BAD_REQUEST: number = 400;
  static readonly NOT_FOUND: number = 404;
  static readonly SERVER_ERROR: number = 500;
}

function handleResponse(status: number): void {
  if (status === HttpStatus.OK) {
    console.log("Request successful");
  } else if (status === HttpStatus.NOT_FOUND) {
    console.log("Resource not found");
  }
}
  • Usa métodos estáticos para operaciones independientes del estado: Funcionalidades que no requieren estado de instancia.
class DateUtils {
  static formatDate(date: Date, format: string = "yyyy-mm-dd"): string {
    const year = date.getFullYear();
    const month = String(date.getMonth() + 1).padStart(2, "0");
    const day = String(date.getDate()).padStart(2, "0");
    
    return format
      .replace("yyyy", year.toString())
      .replace("mm", month)
      .replace("dd", day);
  }
  
  static getDaysBetween(start: Date, end: Date): number {
    const diffTime = Math.abs(end.getTime() - start.getTime());
    return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
  }
}

const today = new Date();
console.log(DateUtils.formatDate(today)); // "2023-05-15"
  • Considera usar clases de utilidad con solo miembros estáticos: Para agrupar funcionalidades relacionadas.
class ValidationUtils {
  // Clase con solo métodos estáticos
  static isValidEmail(email: string): boolean {
    const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return regex.test(email);
  }
  
  static isValidPassword(password: string): boolean {
    return password.length >= 8 && /[A-Z]/.test(password) && /[0-9]/.test(password);
  }
  
  static isValidUsername(username: string): boolean {
    return /^[a-zA-Z0-9_]{3,20}$/.test(username);
  }
}

// Uso
if (ValidationUtils.isValidEmail("user@example.com")) {
  console.log("Email is valid");
}
  • Usa métodos estáticos para implementar patrones de diseño: Como Factory, Builder o Singleton.
class Product {
  constructor(
    public id: string,
    public name: string,
    public price: number
  ) {}
  
  // Patrón Builder con métodos estáticos
  static builder(): ProductBuilder {
    return new ProductBuilder();
  }
}

class ProductBuilder {
  private id: string = "";
  private name: string = "";
  private price: number = 0;
  
  setId(id: string): ProductBuilder {
    this.id = id;
    return this;
  }
  
  setName(name: string): ProductBuilder {
    this.name = name;
    return this;
  }
  
  setPrice(price: number): ProductBuilder {
    this.price = price;
    return this;
  }
  
  build(): Product {
    return new Product(this.id, this.name, this.price);
  }
}

// Uso del patrón Builder
const product = Product.builder()
  .setId("prod-123")
  .setName("Smartphone")
  .setPrice(599.99)
  .build();

Las propiedades y métodos estáticos son herramientas poderosas en TypeScript que permiten implementar funcionalidades a nivel de clase, compartir estado entre instancias y crear patrones de diseño elegantes. Cuando se utilizan adecuadamente, pueden mejorar significativamente la organización y reutilización del código.

Aprendizajes de esta lección

  1. Comprender el concepto de encapsulación y su importancia en la programación orientada a objetos.
  2. Conocer los modificadores de acceso public, private y protected en TypeScript.
  3. Aprender cómo utilizar los modificadores de acceso para controlar la visibilidad de las propiedades y métodos en una clase.
  4. Entender que las propiedades y métodos public son accesibles desde cualquier lugar, mientras que las private solo desde dentro de la clase y las protected desde la clase y clases heredadas.
  5. Practicar el uso de la encapsulación para ocultar los detalles internos de una clase y evitar accesos no deseados desde el exterior.
  6. Familiarizarse con los beneficios de la encapsulación, como el mantenimiento del código y la prevención de errores al restringir el acceso a ciertos miembros de la clase.

Completa TypeScript y certifícate

Únete a nuestra plataforma y accede a miles de tutoriales, ejercicios prácticos, proyectos reales y nuestro asistente de IA personalizado para acelerar tu aprendizaje.

Asistente IA

Resuelve dudas al instante

Ejercicios

Practica con proyectos reales

Certificados

Valida tus conocimientos

Más de 25.000 desarrolladores ya se han certificado con CertiDevs

⭐⭐⭐⭐⭐
4.9/5 valoración