TypeScript

TypeScript

Tutorial TypeScript: Propiedades y métodos

Aprende a declarar y tipar propiedades y métodos en TypeScript con ejemplos prácticos y buenas prácticas para código seguro y mantenible.

Aprende TypeScript y certifícate

Declaración de propiedades tipadas

Las propiedades son elementos fundamentales en las clases de TypeScript que permiten almacenar datos específicos para cada instancia de objeto. A diferencia de JavaScript, TypeScript nos ofrece la capacidad de tipar estas propiedades, lo que proporciona mayor seguridad y claridad en nuestro código.

Sintaxis básica

Para declarar propiedades tipadas en una clase, debemos especificar el nombre de la propiedad seguido de dos puntos y el tipo de dato que almacenará:

class Product {
  name: string;
  price: number;
  inStock: boolean;
}

En este ejemplo, hemos creado una clase Product con tres propiedades tipadas: name como string, price como number y inStock como boolean.

Inicialización de propiedades

Existen varias formas de inicializar las propiedades de una clase:

  • Inicialización en la declaración:
class Product {
  name: string = "Default Product";
  price: number = 0;
  inStock: boolean = true;
}
  • Inicialización en el constructor:
class Product {
  name: string;
  price: number;
  inStock: boolean;
  
  constructor(name: string, price: number, inStock: boolean) {
    this.name = name;
    this.price = price;
    this.inStock = inStock;
  }
}

// Uso
const laptop = new Product("Laptop", 999.99, true);

Sintaxis abreviada con parámetros del constructor

TypeScript ofrece una sintaxis abreviada muy útil que combina la declaración de propiedades y su inicialización a través del constructor:

class Product {
  constructor(
    public name: string,
    public price: number,
    public inStock: boolean
  ) {}
}

// Uso
const smartphone = new Product("Smartphone", 599.99, true);
console.log(smartphone.name); // "Smartphone"

Al añadir un modificador de acceso (public, private o protected) a los parámetros del constructor, TypeScript automáticamente:

  1. Crea la propiedad con el mismo nombre
  2. Asigna el valor del parámetro a la propiedad

Esta sintaxis reduce significativamente el código repetitivo y es una práctica muy común en proyectos TypeScript.

Tipos complejos para propiedades

Las propiedades pueden utilizar cualquier tipo disponible en TypeScript, desde tipos primitivos hasta tipos más complejos:

class ShoppingCart {
  items: Product[] = [];
  customer: { id: number; name: string } | null = null;
  discountCode?: string;
  lastUpdated: Date = new Date();
}

En este ejemplo:

  • items es un array de objetos Product
  • customer es un objeto literal o null
  • discountCode puede ser un string o undefined (indicado por el operador ?)
  • lastUpdated es un objeto de tipo Date

Propiedades con tipos personalizados

Para mejorar la legibilidad y reutilización, podemos definir tipos personalizados para nuestras propiedades:

type ProductCategory = "Electronics" | "Clothing" | "Books" | "Food";
type ProductStatus = "Available" | "OutOfStock" | "Discontinued";

class Product {
  name: string;
  price: number;
  category: ProductCategory;
  status: ProductStatus;
  
  constructor(name: string, price: number, category: ProductCategory) {
    this.name = name;
    this.price = price;
    this.category = category;
    this.status = "Available";
  }
}

// Uso
const book = new Product("TypeScript Handbook", 29.99, "Books");

Propiedades estáticas tipadas

Las propiedades estáticas pertenecen a la clase en sí, no a las instancias individuales. También pueden ser tipadas:

class PriceCalculator {
  static taxRate: number = 0.21;
  static currencySymbol: string = "€";
  
  static calculateTotalPrice(price: number): number {
    return price * (1 + this.taxRate);
  }
}

// Uso sin instanciar la clase
console.log(PriceCalculator.taxRate); // 0.21
const total = PriceCalculator.calculateTotalPrice(100);
console.log(`${total}${PriceCalculator.currencySymbol}`); // "121€"

Inferencia de tipos en propiedades

TypeScript puede inferir el tipo de una propiedad basándose en su valor inicial:

class Configuration {
  // TypeScript infiere que serverUrl es de tipo string
  serverUrl = "https://api.example.com";
  
  // TypeScript infiere que maxRetries es de tipo number
  maxRetries = 3;
  
  // TypeScript infiere que isProduction es de tipo boolean
  isProduction = false;
}

Sin embargo, es una buena práctica declarar explícitamente los tipos, especialmente para propiedades que no se inicializan inmediatamente o que podrían cambiar de valor.

Propiedades con tipos de unión

Podemos definir propiedades que acepten múltiples tipos utilizando tipos de unión:

class UserProfile {
  id: string | number;
  preferences: string[] | Record<string, boolean>;
  
  constructor(id: string | number) {
    this.id = id;
    this.preferences = [];
  }
  
  updatePreferences(prefs: string[] | Record<string, boolean>): void {
    this.preferences = prefs;
  }
}

const user1 = new UserProfile("user123");
user1.updatePreferences(["darkMode", "notifications"]);

const user2 = new UserProfile(456);
user2.updatePreferences({ darkMode: true, notifications: false });

En este ejemplo, tanto id como preferences pueden aceptar diferentes tipos de datos, lo que proporciona flexibilidad manteniendo la seguridad de tipos.

Buenas prácticas

  • Declara siempre el tipo de tus propiedades, incluso cuando TypeScript pueda inferirlo.
  • Inicializa las propiedades siempre que sea posible, ya sea en la declaración o en el constructor.
  • Utiliza la sintaxis abreviada del constructor para reducir código repetitivo.
  • Considera usar tipos personalizados para propiedades con valores específicos o estructuras complejas.
  • Mantén la coherencia en el estilo de declaración de propiedades en todo tu proyecto.

Métodos de instancia y su tipado

Los métodos de instancia son funciones que pertenecen a los objetos creados a partir de una clase. En TypeScript, estos métodos pueden ser tipados con precisión, lo que proporciona mayor seguridad y mejor documentación del código. A diferencia de las propiedades que almacenan datos, los métodos definen el comportamiento de los objetos.

Declaración básica de métodos

Para declarar un método en una clase de TypeScript, utilizamos una sintaxis similar a la de las funciones regulares, pero dentro del cuerpo de la clase:

class Product {
  name: string;
  price: number;
  
  constructor(name: string, price: number) {
    this.name = name;
    this.price = price;
  }
  
  displayInfo(): void {
    console.log(`Product: ${this.name}, Price: $${this.price}`);
  }
  
  applyDiscount(percentage: number): number {
    return this.price * (1 - percentage / 100);
  }
}

const laptop = new Product("Gaming Laptop", 1299.99);
laptop.displayInfo(); // "Product: Gaming Laptop, Price: $1299.99"
const discountedPrice = laptop.applyDiscount(10);
console.log(`Discounted price: $${discountedPrice}`); // "Discounted price: $1169.99"

En este ejemplo:

  • displayInfo() es un método que no devuelve ningún valor (tipo void)
  • applyDiscount() recibe un parámetro numérico y devuelve otro número (tipo number)

Tipado de parámetros y valores de retorno

La principal ventaja de TypeScript es poder especificar los tipos de los parámetros y valores de retorno de los métodos:

class ShoppingCart {
  items: Product[] = [];
  
  addItem(product: Product): void {
    this.items.push(product);
  }
  
  removeItem(productName: string): boolean {
    const initialLength = this.items.length;
    this.items = this.items.filter(item => item.name !== productName);
    return this.items.length !== initialLength;
  }
  
  calculateTotal(): number {
    return this.items.reduce((total, item) => total + item.price, 0);
  }
  
  getItemCount(): number {
    return this.items.length;
  }
}

Cada método tiene claramente definido:

  • Los tipos de sus parámetros (como product: Product o productName: string)
  • El tipo de retorno (void, boolean, number)

Métodos con parámetros opcionales y predeterminados

Al igual que con las funciones regulares, los métodos pueden tener parámetros opcionales o valores predeterminados:

class OrderProcessor {
  processOrder(orderId: string, quantity: number, express?: boolean): void {
    // El parámetro express es opcional
    const shippingMethod = express ? "Express" : "Standard";
    console.log(`Processing order ${orderId} for ${quantity} items with ${shippingMethod} shipping`);
  }
  
  calculateShipping(distance: number, weight: number, priority: boolean = false): number {
    // El parámetro priority tiene un valor predeterminado de false
    const baseRate = distance * 0.1;
    const weightFactor = weight * 0.5;
    const priorityMultiplier = priority ? 1.5 : 1;
    
    return (baseRate + weightFactor) * priorityMultiplier;
  }
}

const processor = new OrderProcessor();
processor.processOrder("ORD-123", 3); // Sin el parámetro opcional
processor.processOrder("ORD-456", 1, true); // Con el parámetro opcional

const standardShipping = processor.calculateShipping(100, 5); // Usa el valor predeterminado
const priorityShipping = processor.calculateShipping(100, 5, true); // Sobrescribe el valor predeterminado

Sobrecarga de métodos

TypeScript permite la sobrecarga de métodos, lo que significa definir múltiples firmas para un mismo método:

class Calculator {
  // Sobrecargas
  add(a: number, b: number): number;
  add(a: string, b: string): string;
  
  // Implementación
  add(a: number | string, b: number | string): number | string {
    if (typeof a === "number" && typeof b === "number") {
      return a + b;
    }
    if (typeof a === "string" && typeof b === "string") {
      return a.concat(b);
    }
    throw new Error("Parameters must be both numbers or both strings");
  }
}

const calc = new Calculator();
const sum = calc.add(5, 3); // 8
const text = calc.add("Hello, ", "World!"); // "Hello, World!"

Las sobrecargas proporcionan una interfaz más clara para los usuarios de la clase, ya que TypeScript mostrará las firmas específicas según los argumentos proporcionados.

Métodos asíncronos

Los métodos pueden ser asíncronos utilizando async/await y devolver promesas tipadas:

class DataService {
  async fetchUserData(userId: string): Promise<UserData> {
    const response = await fetch(`https://api.example.com/users/${userId}`);
    
    if (!response.ok) {
      throw new Error(`Failed to fetch user data: ${response.statusText}`);
    }
    
    return await response.json() as UserData;
  }
  
  async saveUserPreferences(userId: string, preferences: UserPreferences): Promise<boolean> {
    try {
      const response = await fetch(`https://api.example.com/users/${userId}/preferences`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(preferences)
      });
      
      return response.ok;
    } catch (error) {
      console.error("Failed to save preferences:", error);
      return false;
    }
  }
}

// Tipos utilizados
interface UserData {
  id: string;
  name: string;
  email: string;
}

interface UserPreferences {
  theme: "light" | "dark";
  notifications: boolean;
}

En este ejemplo:

  • fetchUserData() devuelve una Promise<UserData>
  • saveUserPreferences() devuelve una Promise<boolean>

Métodos con tipos genéricos

Los métodos pueden utilizar tipos genéricos para crear funciones más flexibles pero tipadas:

class Repository<T> {
  private items: T[] = [];
  
  add(item: T): void {
    this.items.push(item);
  }
  
  getById<K extends keyof T>(property: K, value: T[K]): T | undefined {
    return this.items.find(item => item[property] === value);
  }
  
  filter(predicate: (item: T) => boolean): T[] {
    return this.items.filter(predicate);
  }
  
  map<R>(transformer: (item: T) => R): R[] {
    return this.items.map(transformer);
  }
}

// Uso con un tipo específico
interface Product {
  id: number;
  name: string;
  price: number;
}

const productRepo = new Repository<Product>();
productRepo.add({ id: 1, name: "Laptop", price: 999 });
productRepo.add({ id: 2, name: "Phone", price: 699 });

const laptop = productRepo.getById("id", 1);
const expensiveProducts = productRepo.filter(p => p.price > 800);
const productNames = productRepo.map(p => p.name); // string[]

Métodos con tipos de unión y guardas de tipo

Los métodos pueden trabajar con tipos de unión y utilizar guardas de tipo para manejar diferentes casos:

type PaymentMethod = CreditCard | BankTransfer | PayPal;

interface CreditCard {
  type: "credit";
  cardNumber: string;
  expiryDate: string;
}

interface BankTransfer {
  type: "bank";
  accountNumber: string;
  bankCode: string;
}

interface PayPal {
  type: "paypal";
  email: string;
}

class PaymentProcessor {
  processPayment(amount: number, method: PaymentMethod): boolean {
    console.log(`Processing payment of $${amount}`);
    
    // Guarda de tipo usando la propiedad discriminante "type"
    if (method.type === "credit") {
      console.log(`Using credit card ending in ${method.cardNumber.slice(-4)}`);
      // Lógica específica para tarjetas de crédito
    } else if (method.type === "bank") {
      console.log(`Using bank account ${method.accountNumber}`);
      // Lógica específica para transferencias bancarias
    } else {
      console.log(`Using PayPal account ${method.email}`);
      // Lógica específica para PayPal
    }
    
    return true;
  }
}

Encadenamiento de métodos

Podemos implementar el patrón de encadenamiento (method chaining) haciendo que los métodos devuelvan this:

class QueryBuilder {
  private filters: Record<string, any> = {};
  private sortField: string | null = null;
  private sortDirection: "asc" | "desc" = "asc";
  private limitValue: number | null = null;
  
  where(field: string, value: any): this {
    this.filters[field] = value;
    return this;
  }
  
  orderBy(field: string, direction: "asc" | "desc" = "asc"): this {
    this.sortField = field;
    this.sortDirection = direction;
    return this;
  }
  
  limit(value: number): this {
    this.limitValue = value;
    return this;
  }
  
  execute(): object[] {
    console.log("Executing query with:");
    console.log("Filters:", this.filters);
    console.log("Sort:", this.sortField, this.sortDirection);
    console.log("Limit:", this.limitValue);
    
    // Aquí iría la lógica real de consulta
    return [];
  }
}

// Uso con encadenamiento
const results = new QueryBuilder()
  .where("category", "electronics")
  .where("price", { $lt: 1000 })
  .orderBy("rating", "desc")
  .limit(10)
  .execute();

Buenas prácticas para métodos de instancia

  • Nombres descriptivos: Usa verbos que describan claramente la acción que realiza el método.
  • Responsabilidad única: Cada método debe hacer una sola cosa y hacerla bien.
  • Parámetros tipados: Siempre especifica los tipos de los parámetros.
  • Valores de retorno: Declara explícitamente el tipo de retorno, incluso cuando sea void.
  • Métodos pequeños: Mantén los métodos concisos y enfocados en una tarea específica.
  • Documentación: Considera usar comentarios JSDoc para documentar métodos complejos:
class DataAnalyzer {
  /**
   * Calcula estadísticas sobre un conjunto de datos numéricos
   * @param data - Array de números para analizar
   * @returns Objeto con estadísticas calculadas
   */
  calculateStats(data: number[]): { min: number; max: number; avg: number } {
    if (data.length === 0) {
      throw new Error("Cannot calculate stats for empty dataset");
    }
    
    const min = Math.min(...data);
    const max = Math.max(...data);
    const sum = data.reduce((acc, val) => acc + val, 0);
    const avg = sum / data.length;
    
    return { min, max, avg };
  }
}

Propiedades opcionales

Las propiedades opcionales en TypeScript permiten definir atributos que pueden existir o no en una clase, lo que aporta flexibilidad al diseño de nuestros objetos. Esta característica es especialmente útil cuando trabajamos con estructuras de datos donde ciertos campos no siempre están presentes o cuando implementamos patrones como el constructor con opciones.

Sintaxis básica

Para declarar una propiedad como opcional, simplemente añadimos el símbolo de interrogación (?) después del nombre de la propiedad:

class User {
  id: number;
  name: string;
  email: string;
  phoneNumber?: string;
  address?: string;
}

En este ejemplo, phoneNumber y address son propiedades opcionales, mientras que id, name y email son obligatorias.

Inicialización y acceso seguro

Al trabajar con propiedades opcionales, debemos tener en cuenta que pueden ser undefined:

class User {
  id: number;
  name: string;
  email: string;
  phoneNumber?: string;
  
  constructor(id: number, name: string, email: string, phoneNumber?: string) {
    this.id = id;
    this.name = name;
    this.email = email;
    this.phoneNumber = phoneNumber;
  }
  
  displayContact(): string {
    // Uso del operador de encadenamiento opcional
    return `Contact: ${this.email} ${this.phoneNumber?.length ? `/ ${this.phoneNumber}` : ''}`;
  }
}

const user1 = new User(1, "Alice", "alice@example.com");
const user2 = new User(2, "Bob", "bob@example.com", "555-1234");

console.log(user1.displayContact()); // "Contact: alice@example.com"
console.log(user2.displayContact()); // "Contact: bob@example.com / 555-1234"

El operador de encadenamiento opcional (?.) nos permite acceder de forma segura a propiedades que podrían ser undefined, evitando errores en tiempo de ejecución.

Patrón de opciones con propiedades opcionales

Un uso común de las propiedades opcionales es implementar el patrón de opciones, donde pasamos un objeto de configuración al constructor:

interface ProductOptions {
  description?: string;
  category?: string;
  tags?: string[];
  discount?: number;
  featured?: boolean;
}

class Product {
  id: number;
  name: string;
  price: number;
  description?: string;
  category?: string;
  tags?: string[];
  discount?: number;
  featured: boolean = false;
  
  constructor(id: number, name: string, price: number, options?: ProductOptions) {
    this.id = id;
    this.name = name;
    this.price = price;
    
    // Asignamos las opciones si están presentes
    if (options) {
      this.description = options.description;
      this.category = options.category;
      this.tags = options.tags;
      this.discount = options.discount;
      this.featured = options.featured ?? false;
    }
  }
  
  getDisplayPrice(): number {
    // Aplicamos descuento si existe
    return this.discount ? this.price * (1 - this.discount) : this.price;
  }
}

// Uso básico sin opciones
const basicProduct = new Product(1, "Basic Widget", 19.99);

// Uso con algunas opciones
const featuredProduct = new Product(2, "Premium Widget", 49.99, {
  description: "Our best-selling widget with premium features",
  category: "Premium",
  featured: true,
  discount: 0.1 // 10% de descuento
});

console.log(basicProduct.getDisplayPrice()); // 19.99
console.log(featuredProduct.getDisplayPrice()); // 44.991 (con 10% de descuento)

Este patrón hace que nuestro código sea más legible y mantenible, especialmente cuando tenemos muchos parámetros opcionales.

Propiedades opcionales con tipos de unión

Podemos combinar propiedades opcionales con tipos de unión para mayor flexibilidad:

class Notification {
  id: string;
  title: string;
  message: string;
  timestamp: Date;
  // Puede ser un string, un objeto User, o no existir
  recipient?: string | { id: number; email: string };
  // Puede ser "info", "warning", "error" o no existir (undefined)
  type?: "info" | "warning" | "error";
  
  constructor(id: string, title: string, message: string) {
    this.id = id;
    this.title = title;
    this.message = message;
    this.timestamp = new Date();
  }
  
  send(): boolean {
    console.log(`Sending notification: ${this.title}`);
    
    if (this.recipient) {
      if (typeof this.recipient === "string") {
        console.log(`To email: ${this.recipient}`);
      } else {
        console.log(`To user: ${this.recipient.email}`);
      }
    } else {
      console.log("Broadcasting to all users");
    }
    
    return true;
  }
}

// Notificación general sin destinatario específico
const broadcastNotification = new Notification(
  "not-1",
  "System Maintenance",
  "The system will be down for maintenance tonight"
);

// Notificación con destinatario específico
const userNotification = new Notification(
  "not-2",
  "Welcome!",
  "Thank you for joining our platform"
);
userNotification.recipient = { id: 42, email: "new.user@example.com" };
userNotification.type = "info";

broadcastNotification.send();
userNotification.send();

Propiedades opcionales vs. inicialización con valores por defecto

Es importante entender la diferencia entre una propiedad opcional y una propiedad con valor por defecto:

class Configuration {
  // Propiedad opcional: puede ser string o undefined
  apiKey?: string;
  
  // Propiedad con valor por defecto: siempre será string
  theme: string = "light";
  
  // Propiedad opcional con valor por defecto si se proporciona
  maxRetries?: number;
  
  constructor(apiKey?: string, theme?: string, maxRetries?: number) {
    if (apiKey) this.apiKey = apiKey;
    if (theme) this.theme = theme;
    this.maxRetries = maxRetries;
  }
  
  isValid(): boolean {
    // Una configuración es válida solo si tiene apiKey
    return !!this.apiKey;
  }
}

const config1 = new Configuration(); // apiKey: undefined, theme: "light", maxRetries: undefined
const config2 = new Configuration("abc123"); // apiKey: "abc123", theme: "light", maxRetries: undefined
const config3 = new Configuration("xyz789", "dark", 3); // apiKey: "xyz789", theme: "dark", maxRetries: 3

console.log(config1.isValid()); // false
console.log(config2.isValid()); // true
  • Una propiedad opcional (apiKey?) puede no existir en el objeto.
  • Una propiedad con valor por defecto (theme = "light") siempre existirá, pero tendrá un valor predeterminado si no se especifica otro.

Propiedades opcionales en interfaces implementadas

Cuando una clase implementa una interfaz con propiedades opcionales, debe respetar esa opcionalidad:

interface Vehicle {
  make: string;
  model: string;
  year: number;
  color?: string;
  features?: string[];
}

class Car implements Vehicle {
  make: string;
  model: string;
  year: number;
  color?: string;
  features?: string[];
  
  // Propiedad adicional específica de Car
  fuelType: string;
  
  constructor(make: string, model: string, year: number, fuelType: string) {
    this.make = make;
    this.model = model;
    this.year = year;
    this.fuelType = fuelType;
  }
  
  addFeature(feature: string): void {
    // Inicializamos el array si no existe
    if (!this.features) {
      this.features = [];
    }
    this.features.push(feature);
  }
}

const myCar = new Car("Toyota", "Corolla", 2022, "Hybrid");
myCar.color = "Blue";
myCar.addFeature("Navigation");
myCar.addFeature("Bluetooth");

console.log(myCar);

Operador de coalescencia nula con propiedades opcionales

El operador de coalescencia nula (??) es muy útil al trabajar con propiedades opcionales, ya que nos permite proporcionar un valor predeterminado cuando la propiedad es undefined:

class ShoppingCart {
  items: { product: string; price: number }[] = [];
  customer?: { name: string; email: string };
  discountCode?: string;
  
  calculateTotal(): number {
    const subtotal = this.items.reduce((sum, item) => sum + item.price, 0);
    const discount = this.getDiscountPercentage();
    
    return subtotal * (1 - discount);
  }
  
  getDiscountPercentage(): number {
    // Si hay un código de descuento, aplicamos 10%, de lo contrario 0%
    return this.discountCode ? 0.1 : 0;
  }
  
  getCustomerName(): string {
    // Usamos el operador ?? para proporcionar un valor predeterminado
    return this.customer?.name ?? "Guest";
  }
  
  checkout(): boolean {
    const total = this.calculateTotal();
    console.log(`Checkout for ${this.getCustomerName()}`);
    console.log(`Total: $${total.toFixed(2)}`);
    
    // Verificamos si tenemos información del cliente
    if (this.customer) {
      console.log(`Sending receipt to ${this.customer.email}`);
    } else {
      console.log("No customer information provided");
    }
    
    return true;
  }
}

const cart1 = new ShoppingCart();
cart1.items.push({ product: "Keyboard", price: 59.99 });
cart1.items.push({ product: "Mouse", price: 29.99 });

const cart2 = new ShoppingCart();
cart2.items.push({ product: "Monitor", price: 299.99 });
cart2.customer = { name: "Alice", email: "alice@example.com" };
cart2.discountCode = "SUMMER10";

cart1.checkout(); // Checkout for Guest, sin descuento
cart2.checkout(); // Checkout for Alice, con 10% de descuento

Buenas prácticas con propiedades opcionales

  • Usa propiedades opcionales cuando un campo genuinamente puede no existir.
  • Considera valores por defecto en lugar de propiedades opcionales cuando casi siempre debería haber un valor.
  • Verifica la existencia de propiedades opcionales antes de usarlas (con ?., ?? o comprobaciones explícitas).
  • Documenta el propósito de las propiedades opcionales para que otros desarrolladores entiendan cuándo pueden estar ausentes.
  • Inicializa colecciones vacías en lugar de hacerlas opcionales cuando sea apropiado (por ejemplo, tags: string[] = [] en lugar de tags?: string[]).
  • Combina con destructuración para un código más limpio al trabajar con objetos de opciones:
interface UserOptions {
  role?: string;
  isActive?: boolean;
  preferences?: {
    language?: string;
    notifications?: boolean;
  };
}

class UserManager {
  createUser(name: string, email: string, options?: UserOptions): void {
    // Destructuración con valores por defecto
    const { 
      role = "user", 
      isActive = true,
      preferences = {} 
    } = options || {};
    
    const { 
      language = "en", 
      notifications = true 
    } = preferences;
    
    console.log(`Creating user: ${name} (${email})`);
    console.log(`Role: ${role}, Active: ${isActive}`);
    console.log(`Language: ${language}, Notifications: ${notifications}`);
    
    // Lógica para crear el usuario...
  }
}

const userManager = new UserManager();

// Diferentes niveles de opcionalidad
userManager.createUser("John", "john@example.com");
userManager.createUser("Sarah", "sarah@example.com", { role: "admin" });
userManager.createUser("Mike", "mike@example.com", { 
  isActive: false,
  preferences: { language: "es" }
});

Propiedades de solo lectura (readonly)

Las propiedades de solo lectura en TypeScript proporcionan un mecanismo de inmutabilidad que permite definir atributos que pueden asignarse durante la inicialización pero que no pueden modificarse posteriormente. Esta característica refuerza la integridad de los datos y ayuda a prevenir cambios accidentales en propiedades que deberían permanecer constantes.

Sintaxis básica

Para declarar una propiedad como de solo lectura, utilizamos la palabra clave readonly antes del nombre de la propiedad:

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

const laptop = new Product("P001", "Gaming Laptop", 1299.99);
console.log(laptop.id); // "P001"

// Error: Cannot assign to 'id' because it is a read-only property
// laptop.id = "P002";

En este ejemplo, la propiedad id solo puede establecerse en el constructor o en su declaración inicial, pero no puede modificarse después.

Inicialización de propiedades readonly

Las propiedades de solo lectura pueden inicializarse de varias maneras:

  • En la declaración:
class Configuration {
  readonly apiVersion: string = "v1";
  readonly maxConnections: number = 100;
  readonly createdAt: Date = new Date();
  
  // Otras propiedades y métodos...
}
  • En el constructor:
class User {
  readonly id: string;
  readonly registrationDate: Date;
  name: string;
  
  constructor(id: string, name: string) {
    this.id = id;
    this.registrationDate = new Date();
    this.name = name;
  }
}
  • Con la sintaxis abreviada del constructor:
class Transaction {
  constructor(
    readonly id: string,
    readonly timestamp: Date,
    readonly amount: number,
    public status: "pending" | "completed" | "failed" = "pending"
  ) {}
  
  completeTransaction(): void {
    this.status = "completed";
    // No podemos modificar las propiedades readonly:
    // this.amount = 0; // Error
  }
}

const payment = new Transaction("T123", new Date(), 99.99);

Propiedades readonly vs const

Es importante entender la diferencia entre propiedades readonly y variables const:

class Example {
  readonly readonlyProperty: number = 42;
  static readonly STATIC_CONSTANT: string = "FIXED_VALUE";
  
  demonstrateDifference(): void {
    // Variables const
    const localConstant = 100;
    // localConstant = 200; // Error: no se puede reasignar
    
    // Pero si localConstant es un objeto, sus propiedades pueden modificarse
    const user = { name: "Alice" };
    user.name = "Bob"; // Esto es válido
    
    // Con readonly, la propiedad en sí no puede modificarse
    // this.readonlyProperty = 43; // Error
    
    console.log(Example.STATIC_CONSTANT); // Acceso a constante estática
  }
}
  • readonly se aplica a propiedades de clases e interfaces
  • const se aplica a variables y no puede reasignarse
  • Un objeto asignado a una variable const puede tener sus propiedades modificadas
  • Una propiedad readonly no puede modificarse, independientemente de dónde se use

Propiedades readonly con tipos complejos

Cuando una propiedad readonly contiene un objeto o array, la referencia es inmutable, pero el contenido puede modificarse:

class Department {
  readonly name: string;
  readonly code: string;
  readonly employees: string[];
  
  constructor(name: string, code: string, initialEmployees: string[] = []) {
    this.name = name;
    this.code = code;
    this.employees = initialEmployees;
  }
  
  addEmployee(name: string): void {
    // Podemos modificar el contenido del array readonly
    this.employees.push(name);
    
    // Pero no podemos reasignar el array
    // this.employees = []; // Error
  }
}

const engineering = new Department("Engineering", "ENG", ["Alice"]);
engineering.addEmployee("Bob");
console.log(engineering.employees); // ["Alice", "Bob"]

Para lograr una inmutabilidad completa, podemos usar técnicas adicionales:

class ImmutableCollection {
  readonly data: ReadonlyArray<number>;
  
  constructor(initialData: number[]) {
    // Asignamos un ReadonlyArray
    this.data = initialData;
  }
  
  // Para "modificar", creamos una nueva instancia
  addItem(item: number): ImmutableCollection {
    const newData = [...this.data, item];
    return new ImmutableCollection(newData);
  }
  
  getItems(): ReadonlyArray<number> {
    return this.data;
  }
}

let collection = new ImmutableCollection([1, 2, 3]);
// collection.data.push(4); // Error: push no existe en ReadonlyArray
// collection.data[0] = 99; // Error: no se puede asignar a un índice

// En su lugar, creamos una nueva colección
collection = collection.addItem(4);
console.log(collection.data); // [1, 2, 3, 4]

Propiedades readonly en interfaces

Las propiedades de solo lectura también pueden definirse en interfaces:

interface Point {
  readonly x: number;
  readonly y: number;
}

class Circle {
  readonly center: Point;
  radius: number;
  
  constructor(x: number, y: number, radius: number) {
    this.center = { x, y };
    this.radius = radius;
  }
  
  move(newX: number, newY: number): Circle {
    // No podemos modificar center directamente
    // this.center.x = newX; // Error
    
    // Creamos un nuevo círculo
    return new Circle(newX, newY, this.radius);
  }
  
  resize(newRadius: number): void {
    // El radio no es readonly, así que podemos modificarlo
    this.radius = newRadius;
  }
}

let circle = new Circle(0, 0, 5);
circle = circle.move(10, 10);

Propiedades readonly estáticas

Las propiedades estáticas también pueden ser de solo lectura, lo que es útil para definir constantes a nivel de clase:

class AppSettings {
  // Constantes de configuración
  static readonly API_URL: string = "https://api.example.com";
  static readonly MAX_RETRY_ATTEMPTS: number = 3;
  static readonly DEFAULT_TIMEOUT: number = 5000;
  
  // Valores calculados que no deben cambiar
  static readonly APP_VERSION: string = AppSettings.calculateVersion();
  
  private static calculateVersion(): string {
    // Lógica para determinar la versión
    return "1.2.3";
  }
  
  static getApiEndpoint(resource: string): string {
    return `${AppSettings.API_URL}/${resource}`;
  }
}

// Uso
console.log(AppSettings.API_URL);
const usersEndpoint = AppSettings.getApiEndpoint("users");

// Error: Cannot assign to 'API_URL' because it is a read-only property
// AppSettings.API_URL = "https://new-api.example.com";

Readonly en mapeos de tipos

TypeScript proporciona el tipo de utilidad Readonly<T> que convierte todas las propiedades de un tipo en solo lectura:

interface UserData {
  id: string;
  name: string;
  email: string;
  preferences: {
    theme: string;
    notifications: boolean;
  };
}

class UserProfile {
  readonly data: Readonly<UserData>;
  
  constructor(userData: UserData) {
    this.data = userData;
  }
  
  updatePreferences(theme?: string, notifications?: boolean): UserProfile {
    // Creamos un nuevo objeto con los cambios
    const newData: UserData = {
      ...this.data,
      preferences: {
        ...this.data.preferences,
        ...(theme !== undefined && { theme }),
        ...(notifications !== undefined && { notifications })
      }
    };
    
    return new UserProfile(newData);
  }
}

let profile = new UserProfile({
  id: "user123",
  name: "John Doe",
  email: "john@example.com",
  preferences: {
    theme: "light",
    notifications: true
  }
});

// No podemos modificar directamente
// profile.data.name = "Jane"; // Error
// profile.data.preferences.theme = "dark"; // Error

// En su lugar, creamos un nuevo perfil con los cambios
profile = profile.updatePreferences("dark");
console.log(profile.data.preferences.theme); // "dark"

Readonly en genéricos

Podemos crear clases genéricas que aprovechen las propiedades de solo lectura:

class ReadonlyRepository<T extends { id: string }> {
  private items: Map<string, Readonly<T>>;
  
  constructor(initialItems: T[] = []) {
    this.items = new Map();
    initialItems.forEach(item => this.items.set(item.id, Object.freeze({...item})));
  }
  
  getById(id: string): Readonly<T> | undefined {
    return this.items.get(id);
  }
  
  getAll(): ReadonlyArray<Readonly<T>> {
    return Array.from(this.items.values());
  }
  
  // Para "modificar", creamos nuevas versiones
  update(id: string, updates: Partial<Omit<T, "id">>): boolean {
    const existing = this.items.get(id);
    if (!existing) return false;
    
    const updated = { ...existing, ...updates };
    this.items.set(id, Object.freeze(updated));
    return true;
  }
}

interface Product {
  id: string;
  name: string;
  price: number;
}

const productRepo = new ReadonlyRepository<Product>([
  { id: "p1", name: "Laptop", price: 999 }
]);

const laptop = productRepo.getById("p1");
// laptop.price = 899; // Error: no se puede modificar

// En su lugar, usamos el método update
productRepo.update("p1", { price: 899 });

Buenas prácticas para propiedades readonly

  • Usa readonly para identificadores: Los IDs, códigos y otros identificadores únicos son candidatos ideales para propiedades readonly.
  • Considera readonly para fechas de creación: Timestamps como fechas de creación normalmente no deberían cambiar.
  • Aplica readonly a configuraciones inmutables: Valores de configuración que no deben cambiar durante la vida del objeto.
  • Combina readonly con métodos inmutables: Para objetos complejos, implementa métodos que devuelvan nuevas instancias en lugar de modificar el estado.
  • Usa ReadonlyArray y ReadonlyMap: Para colecciones que no deben modificarse.
  • Considera Object.freeze: Para una inmutabilidad más profunda, puedes combinar readonly con Object.freeze().
class ConfigurationManager {
  readonly settings: Readonly<Record<string, any>>;
  
  constructor(initialSettings: Record<string, any>) {
    // Congelamos el objeto para prevenir modificaciones profundas
    this.settings = Object.freeze({...initialSettings});
  }
  
  // Para "modificar", creamos una nueva instancia
  withSetting(key: string, value: any): ConfigurationManager {
    return new ConfigurationManager({
      ...this.settings,
      [key]: value
    });
  }
}

let config = new ConfigurationManager({
  apiUrl: "https://api.example.com",
  timeout: 5000,
  retryAttempts: 3
});

// No podemos modificar directamente
// config.settings.timeout = 10000; // Error

// En su lugar, creamos una nueva configuración
config = config.withSetting("timeout", 10000);
console.log(config.settings.timeout); // 10000

Las propiedades de solo lectura son una herramienta poderosa para diseñar APIs más seguras y predecibles, especialmente cuando se combinan con patrones de programación inmutables que favorecen la creación de nuevos objetos sobre la modificación de los existentes.

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 TypeScript online

Ejercicios de esta lección Propiedades y métodos

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

Funciones

TypeScript
Test

Reto composición de funciones

TypeScript
Código

Reto tipos especiales

TypeScript
Código

Reto tipos genéricos

TypeScript
Código

Módulos

TypeScript
Test

Polimorfismo

TypeScript
Código

Funciones TypeScript

TypeScript
Código

Interfaces

TypeScript
Puzzle

Funciones puras

TypeScript
Puzzle

Reto namespaces

TypeScript
Código

Funciones flecha

TypeScript
Puzzle

Polimorfismo

TypeScript
Test

Operadores

TypeScript
Test

Conversor de unidades

TypeScript
Proyecto

Funciones flecha

TypeScript
Test

Control de flujo

TypeScript
Código

Herencia

TypeScript
Puzzle

Clases

TypeScript
Puzzle

Proyecto validación de tipado

TypeScript
Proyecto

Clases y objetos

TypeScript
Código

Encapsulación

TypeScript
Test

Herencia

TypeScript
Test

Proyecto sistema de votación

TypeScript
Proyecto

Reto genéricos con clases

TypeScript
Código

Inmutabilidad

TypeScript
Puzzle

Interfaces

TypeScript
Test

Funciones de alto orden

TypeScript
Test

Reto map y filter

TypeScript
Código

Control de flujo

TypeScript
Test

Interfaces

TypeScript
Código

Reto funciones orden superior

TypeScript
Código

Herencia y clases abstractas

TypeScript
Código

Reto tipos mapped

TypeScript
Código

Herencia de clases

TypeScript
Código

Reto funciones puras

TypeScript
Código

Variables y constantes

TypeScript
Puzzle

Introducción a TypeScript

TypeScript
Test

Reto testing unitario

TypeScript
Código

Funciones de primera clase

TypeScript
Puzzle

Clases

TypeScript
Test

OOP y CRUD en TypeScript

TypeScript
Proyecto

Interfaces y su implementación

TypeScript
Código

Tipos genéricos

TypeScript
Test

Namespaces

TypeScript
Test

Proyecto calculadora gastos

TypeScript
Proyecto

Operadores y expresiones

TypeScript
Código

Proyecto generador de contraseñas

TypeScript
Proyecto

Reto unión e intersección

TypeScript
Código

Encapsulación

TypeScript
Puzzle

Tipos de unión e intersección

TypeScript
Test

Tipos de unión e intersección

TypeScript
Puzzle

Reto hola mundo en TS

TypeScript
Código

Variables y constantes

TypeScript
Código

Funciones puras

TypeScript
Test

Control de flujo

TypeScript
Código

Introducción a TypeScript

TypeScript
Código

Resolución de módulos

TypeScript
Test

Control de flujo

TypeScript
Puzzle

Reto tipos de utilidad

TypeScript
Código

Reto tipos literales y condicionales

TypeScript
Código

Reto exportar e importar

TypeScript
Código

Propiedades y métodos

TypeScript
Código

Tipos de utilidad

TypeScript
Test

Clases y objetos

TypeScript
Código

Tipos de datos, variables y constantes

TypeScript
Código

Proyecto Minigestor de tareas

TypeScript
Proyecto

Operadores

TypeScript
Puzzle

Funciones flecha y contexto

TypeScript
Código

Funciones

TypeScript
Puzzle

Reto type aliases

TypeScript
Código

Funciones de alto orden

TypeScript
Puzzle

Funciones y parámetros tipados

TypeScript
Código

Tipos literales

TypeScript
Puzzle

Reto enums

TypeScript
Código

Tipos de utilidad

TypeScript
Puzzle

Modificadores de acceso y encapsulación

TypeScript
Código

Polimorfismo

TypeScript
Puzzle

Tipos genéricos

TypeScript
Puzzle

Reto módulos

TypeScript
Código

Tipos literales

TypeScript
Test

Inmutabilidad

TypeScript
Test

Proyecto Generator de datos

TypeScript
Proyecto

Variables y constantes

TypeScript
Test

Funciones de primera clase

TypeScript
Test

Todas las lecciones de TypeScript

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

Introducción A Typescript

TypeScript

Introducción Y Entorno

Instalación Y Configuración De Typescript

TypeScript

Introducción Y Entorno

Tipos De Datos, Variables Y Constantes

TypeScript

Sintaxis

Operadores Y Expresiones

TypeScript

Sintaxis

Control De Flujo

TypeScript

Sintaxis

Funciones Y Parámetros Tipados

TypeScript

Sintaxis

Funciones Flecha Y Contexto

TypeScript

Sintaxis

Enums

TypeScript

Sintaxis

Type Aliases Y Aserciones De Tipo

TypeScript

Sintaxis

Clases Y Objetos

TypeScript

Programación Orientada A Objetos

Interfaces Y Su Implementación

TypeScript

Programación Orientada A Objetos

Modificadores De Acceso Y Encapsulación

TypeScript

Programación Orientada A Objetos

Herencia Y Clases Abstractas

TypeScript

Programación Orientada A Objetos

Polimorfismo

TypeScript

Programación Orientada A Objetos

Decoradores Básicos

TypeScript

Programación Orientada A Objetos

Propiedades Y Métodos

TypeScript

Programación Orientada A Objetos

Inmutabilidad

TypeScript

Programación Funcional

Funciones Puras

TypeScript

Programación Funcional

Funciones De Primera Clase

TypeScript

Programación Funcional

Funciones De Alto Orden

TypeScript

Programación Funcional

Conceptos Básicos E Inmutabilidad

TypeScript

Programación Funcional

Funciones De Primera Clase Y Orden Superior

TypeScript

Programación Funcional

Composición De Funciones

TypeScript

Programación Funcional

Métodos Funcionales De Arrays (Map, Filter, Reduce)

TypeScript

Programación Funcional

Tipos Literales

TypeScript

Tipos Intermedios Y Avanzados

Tipos Genéricos

TypeScript

Tipos Intermedios Y Avanzados

Tipos De Unión E Intersección

TypeScript

Tipos Intermedios Y Avanzados

Tipos De Utilidad

TypeScript

Tipos Intermedios Y Avanzados

Unknown, Never Y Tipos Especiales

TypeScript

Tipos Intermedios Y Avanzados

Tipos Mapped

TypeScript

Tipos Intermedios Y Avanzados

Genéricos Con Clases E Interfaces

TypeScript

Tipos Intermedios Y Avanzados

Módulos

TypeScript

Namespaces Y Módulos

Namespaces

TypeScript

Namespaces Y Módulos

Resolución De Módulos

TypeScript

Namespaces Y Módulos

Exportación E Importación De Módulos

TypeScript

Namespaces Y Módulos

Introducción A Módulos

TypeScript

Namespaces Y Módulos

Testing Unitario En Typescript

TypeScript

Testing

Accede GRATIS a TypeScript y certifícate

En esta lección

Objetivos de aprendizaje de esta lección

  • Comprender la sintaxis básica para declarar propiedades tipadas en clases de TypeScript
  • Dominar las diferentes formas de inicializar propiedades, incluyendo la sintaxis abreviada del constructor
  • Implementar propiedades opcionales para crear estructuras flexibles usando el operador de interrogación (?)
  • Utilizar propiedades de solo lectura (readonly) para garantizar la inmutabilidad de ciertos datos
  • Aplicar buenas prácticas en la declaración y uso de propiedades para mejorar la seguridad y legibilidad del código