TypeScript

TypeScript

Tutorial TypeScript: Herencia y clases abstractas

TypeScript herencia: técnicas y ejemplos. Domina las técnicas de herencia en TypeScript con ejemplos prácticos y detallados.

Aprende TypeScript y certifícate

Extendiendo clases en TypeScript

La herencia es uno de los pilares fundamentales de la programación orientada a objetos que permite crear nuevas clases basadas en clases existentes. En TypeScript, la herencia nos permite reutilizar código y establecer relaciones jerárquicas entre clases, lo que facilita la organización y mantenimiento del código.

Sintaxis básica de herencia

Para extender una clase en TypeScript, utilizamos la palabra clave extends. Cuando una clase hereda de otra, obtiene automáticamente todas las propiedades y métodos de la clase padre (también llamada superclase o clase base).

class Animal {
  name: string;
  
  constructor(name: string) {
    this.name = name;
  }
  
  move(distance: number = 0): void {
    console.log(`${this.name} moved ${distance} meters.`);
  }
}

class Dog extends Animal {
  breed: string;
  
  constructor(name: string, breed: string) {
    super(name);
    this.breed = breed;
  }
  
  bark(): void {
    console.log('Woof! Woof!');
  }
}

En este ejemplo, Dog es una clase derivada que hereda de Animal. Observa cómo la clase Dog tiene acceso al método move() definido en la clase Animal, además de tener su propio método bark().

Beneficios de la herencia

  • Reutilización de código: Evita duplicar funcionalidades comunes en múltiples clases.
  • Jerarquía de tipos: Establece relaciones "es un" entre objetos (un perro "es un" animal).
  • Extensibilidad: Permite añadir funcionalidades específicas a las clases derivadas.

Herencia y tipos

Una ventaja importante de la herencia en TypeScript es que establece una relación de subtipo. Esto significa que una instancia de una clase derivada puede ser tratada como una instancia de su clase base.

const myDog = new Dog("Rex", "German Shepherd");
myDog.move(10); // Rex moved 10 meters.
myDog.bark();   // Woof! Woof!

// Una variable de tipo Animal puede contener un Dog
const animal: Animal = myDog;
animal.move(5); // Rex moved 5 meters.
// animal.bark(); // Error: Property 'bark' does not exist on type 'Animal'

Herencia múltiple

A diferencia de algunos lenguajes como C++, TypeScript (al igual que JavaScript) no soporta la herencia múltiple directa. Una clase solo puede extender de una única clase base. Sin embargo, este límite puede superarse mediante el uso de interfaces, que veremos en otras lecciones.

Cadenas de herencia

Es posible crear cadenas de herencia donde una clase hereda de otra, que a su vez hereda de una tercera:

class Animal {
  name: string;
  
  constructor(name: string) {
    this.name = name;
  }
  
  move(distance: number = 0): void {
    console.log(`${this.name} moved ${distance} meters.`);
  }
}

class Mammal extends Animal {
  hasFur: boolean = true;
  
  feedMilk(): void {
    console.log(`${this.name} is feeding milk.`);
  }
}

class Cat extends Mammal {
  constructor(name: string) {
    super(name);
  }
  
  move(distance: number = 0): void {
    console.log(`${this.name} prowls silently...`);
    super.move(distance);
  }
  
  meow(): void {
    console.log('Meow!');
  }
}

const myCat = new Cat("Whiskers");
myCat.move(5);     // Whiskers prowls silently... Whiskers moved 5 meters.
myCat.feedMilk();  // Whiskers is feeding milk.
myCat.meow();      // Meow!

En este ejemplo, Cat hereda de Mammal, que a su vez hereda de Animal. La clase Cat tiene acceso a los métodos y propiedades de ambas clases ancestrales.

Restricciones de visibilidad en la herencia

Los modificadores de acceso afectan cómo las propiedades y métodos se heredan:

  • Las propiedades y métodos public son accesibles desde cualquier lugar.
  • Las propiedades y métodos protected son accesibles dentro de la clase y sus subclases.
  • Las propiedades y métodos private solo son accesibles dentro de la clase donde se definen.
class Vehicle {
  public make: string;
  protected year: number;
  private vin: string;
  
  constructor(make: string, year: number, vin: string) {
    this.make = make;
    this.year = year;
    this.vin = vin;
  }
  
  protected getDetails(): string {
    return `${this.make} (${this.year})`;
  }
  
  private getVIN(): string {
    return this.vin;
  }
}

class Car extends Vehicle {
  model: string;
  
  constructor(make: string, model: string, year: number, vin: string) {
    super(make, year, vin);
    this.model = model;
  }
  
  getCarInfo(): string {
    // Podemos acceder a propiedades/métodos protected de la clase base
    return `${this.getDetails()} - ${this.model}`;
    
    // Error: No podemos acceder a propiedades/métodos private de la clase base
    // return this.vin;
    // return this.getVIN();
  }
}

Ejemplo práctico: Sistema de formas geométricas

Veamos un ejemplo más completo que muestra cómo la herencia puede ser útil en un escenario real:

class Shape {
  protected color: string;
  protected filled: boolean;
  
  constructor(color: string = "black", filled: boolean = false) {
    this.color = color;
    this.filled = filled;
  }
  
  getColor(): string {
    return this.color;
  }
  
  isFilled(): boolean {
    return this.filled;
  }
  
  getArea(): number {
    return 0; // Será sobrescrito por las subclases
  }
  
  toString(): string {
    return `A shape with color ${this.color} and ${this.filled ? 'filled' : 'not filled'}`;
  }
}

class Circle extends Shape {
  private radius: number;
  
  constructor(radius: number, color?: string, filled?: boolean) {
    super(color, filled);
    this.radius = radius;
  }
  
  getRadius(): number {
    return this.radius;
  }
  
  getArea(): number {
    return Math.PI * this.radius * this.radius;
  }
  
  getPerimeter(): number {
    return 2 * Math.PI * this.radius;
  }
  
  toString(): string {
    return `A circle with radius=${this.radius}, which is a subclass of ${super.toString()}`;
  }
}

class Rectangle extends Shape {
  protected width: number;
  protected height: number;
  
  constructor(width: number, height: number, color?: string, filled?: boolean) {
    super(color, filled);
    this.width = width;
    this.height = height;
  }
  
  getWidth(): number {
    return this.width;
  }
  
  getHeight(): number {
    return this.height;
  }
  
  getArea(): number {
    return this.width * this.height;
  }
  
  getPerimeter(): number {
    return 2 * (this.width + this.height);
  }
  
  toString(): string {
    return `A rectangle with width=${this.width} and height=${this.height}, which is a subclass of ${super.toString()}`;
  }
}

class Square extends Rectangle {
  constructor(side: number, color?: string, filled?: boolean) {
    super(side, side, color, filled);
  }
  
  getSide(): number {
    return this.width; // width y height son iguales
  }
  
  toString(): string {
    return `A square with side=${this.width}, which is a subclass of ${super.toString()}`;
  }
}

// Uso del sistema de formas
const circle = new Circle(5, "red", true);
console.log(circle.toString());
console.log(`Area: ${circle.getArea().toFixed(2)}`);

const rectangle = new Rectangle(4, 6, "blue", false);
console.log(rectangle.toString());
console.log(`Area: ${rectangle.getArea()}`);

const square = new Square(4, "green", true);
console.log(square.toString());
console.log(`Area: ${square.getArea()}`);

Este ejemplo muestra cómo podemos crear una jerarquía de clases para representar diferentes formas geométricas. La clase base Shape define propiedades y comportamientos comunes, mientras que las clases derivadas implementan funcionalidades específicas para cada tipo de forma.

La herencia nos permite establecer relaciones lógicas entre las clases: un Square es un tipo especial de Rectangle, y tanto Circle como Rectangle son tipos de Shape. Esta estructura facilita la organización del código y permite tratar objetos de diferentes clases de manera uniforme cuando comparten una clase base común.

La palabra clave 'super'

En TypeScript, la palabra clave super es un elemento fundamental cuando trabajamos con herencia. Este mecanismo nos permite acceder y llamar a funciones en la clase padre de una clase derivada, facilitando la reutilización de código y manteniendo la jerarquía de clases.

Funcionamiento básico de super

La palabra clave super tiene dos usos principales:

  • Llamar al constructor de la clase padre
  • Acceder a métodos y propiedades de la clase padre
class BaseClass {
  protected name: string;
  
  constructor(name: string) {
    this.name = name;
  }
  
  greet(): void {
    console.log(`Hello, I'm ${this.name}`);
  }
}

class DerivedClass extends BaseClass {
  private role: string;
  
  constructor(name: string, role: string) {
    // Llamada al constructor de la clase padre
    super(name);
    this.role = role;
  }
  
  greet(): void {
    // Llamada al método de la clase padre
    super.greet();
    console.log(`I work as a ${this.role}`);
  }
}

const employee = new DerivedClass("Alice", "developer");
employee.greet();
// Output:
// Hello, I'm Alice
// I work as a developer

Llamada obligatoria a super() en constructores

Cuando una clase extiende otra, el constructor de la clase derivada debe llamar a super() antes de poder usar this. Esta es una regla estricta en TypeScript:

class Vehicle {
  protected brand: string;
  
  constructor(brand: string) {
    this.brand = brand;
  }
}

class ElectricCar extends Vehicle {
  private batteryCapacity: number;
  
  constructor(brand: string, batteryCapacity: number) {
    // Si comentamos esta línea, TypeScript mostrará un error
    super(brand);
    
    // Ahora podemos usar 'this' con seguridad
    this.batteryCapacity = batteryCapacity;
  }
}

Si intentamos acceder a this antes de llamar a super(), TypeScript nos mostrará un error:

class BadExample extends Vehicle {
  private feature: string;
  
  constructor(brand: string, feature: string) {
    this.feature = feature; // Error: 'this' utilizado antes de llamar a super()
    super(brand);
  }
}

Pasando argumentos a través de super()

Es importante pasar los argumentos correctos al constructor de la clase padre mediante super():

class Product {
  constructor(
    protected id: string,
    protected name: string,
    protected price: number
  ) {}
  
  getInfo(): string {
    return `${this.name} - $${this.price}`;
  }
}

class DiscountedProduct extends Product {
  constructor(
    id: string,
    name: string,
    price: number,
    private discountRate: number
  ) {
    // Pasamos los parámetros requeridos por el constructor padre
    super(id, name, price);
  }
  
  getDiscountedPrice(): number {
    return this.price * (1 - this.discountRate);
  }
  
  // Sobrescribimos el método getInfo
  getInfo(): string {
    return `${super.getInfo()} (${this.discountRate * 100}% off: $${this.getDiscountedPrice().toFixed(2)})`;
  }
}

const laptop = new DiscountedProduct("p1", "Gaming Laptop", 1200, 0.15);
console.log(laptop.getInfo());
// Output: Gaming Laptop - $1200 (15% off: $1020.00)

Acceso a propiedades y métodos de la clase padre

Podemos usar super para acceder a métodos y propiedades de la clase padre, incluso cuando los hemos sobrescrito en la clase derivada:

class Logger {
  protected logPrefix(): string {
    return "LOG:";
  }
  
  log(message: string): void {
    console.log(`${this.logPrefix()} ${message}`);
  }
}

class TimestampLogger extends Logger {
  protected logPrefix(): string {
    // Combinamos el comportamiento del padre con funcionalidad adicional
    return `${super.logPrefix()} [${new Date().toISOString()}]`;
  }
}

class ErrorLogger extends Logger {
  protected logPrefix(): string {
    return "ERROR:";
  }
  
  log(message: string): void {
    // Llamamos al método log del padre pero con nuestro propio prefijo
    super.log(`⚠️ ${message}`);
  }
}

const logger = new Logger();
logger.log("Application started");
// Output: LOG: Application started

const tsLogger = new TimestampLogger();
tsLogger.log("User logged in");
// Output: LOG: [2023-08-15T14:30:45.123Z] User logged in

const errorLogger = new ErrorLogger();
errorLogger.log("Connection failed");
// Output: ERROR: ⚠️ Connection failed

Limitaciones de super

Es importante entender algunas limitaciones al usar super:

  • No podemos acceder a miembros privados de la clase padre
  • super solo puede usarse en clases que extienden otras clases
  • En constructores, super() debe llamarse antes de usar this
class Base {
  public publicMethod(): void {
    console.log("Public method");
  }
  
  protected protectedMethod(): void {
    console.log("Protected method");
  }
  
  private privateMethod(): void {
    console.log("Private method");
  }
}

class Derived extends Base {
  testAccess(): void {
    super.publicMethod();    // Funciona
    super.protectedMethod(); // Funciona
    // super.privateMethod(); // Error: 'privateMethod' es privado
  }
}

Ejemplo práctico: Sistema de notificaciones

Veamos un ejemplo más completo que muestra cómo super facilita la extensión de funcionalidad:

abstract class Notification {
  constructor(
    protected recipient: string,
    protected message: string
  ) {}
  
  abstract send(): boolean;
  
  protected formatMessage(): string {
    return this.message;
  }
  
  protected logDelivery(success: boolean): void {
    const status = success ? "delivered" : "failed";
    console.log(`Notification to ${this.recipient} ${status}`);
  }
}

class EmailNotification extends Notification {
  constructor(
    recipient: string,
    message: string,
    private subject: string
  ) {
    super(recipient, message);
  }
  
  protected formatMessage(): string {
    // Añadimos formato HTML básico al mensaje
    return `<div>${super.formatMessage()}</div>`;
  }
  
  send(): boolean {
    console.log(`Sending email to: ${this.recipient}`);
    console.log(`Subject: ${this.subject}`);
    console.log(`Content: ${this.formatMessage()}`);
    
    // Simulamos el envío
    const success = Math.random() > 0.1; // 90% de éxito
    super.logDelivery(success);
    
    return success;
  }
}

class SMSNotification extends Notification {
  send(): boolean {
    console.log(`Sending SMS to: ${this.recipient}`);
    console.log(`Message: ${this.formatMessage()}`);
    
    // Simulamos el envío
    const success = Math.random() > 0.2; // 80% de éxito
    super.logDelivery(success);
    
    return success;
  }
  
  protected formatMessage(): string {
    // Limitamos la longitud del mensaje para SMS
    const maxLength = 160;
    let message = super.formatMessage();
    
    if (message.length > maxLength) {
      message = message.substring(0, maxLength - 3) + "...";
    }
    
    return message;
  }
}

// Uso del sistema de notificaciones
const email = new EmailNotification(
  "user@example.com",
  "Your account has been verified successfully.",
  "Account Verification"
);

const sms = new SMSNotification(
  "+1234567890",
  "Your verification code is 123456. Valid for 10 minutes."
);

email.send();
sms.send();

En este ejemplo, la clase base Notification define la estructura común para todos los tipos de notificaciones. Las clases derivadas EmailNotification y SMSNotification implementan la funcionalidad específica para cada canal de comunicación.

Observa cómo usamos super para:

  1. Llamar al constructor de la clase padre y pasar los parámetros necesarios
  2. Extender el comportamiento del método formatMessage() en lugar de reemplazarlo completamente
  3. Reutilizar la funcionalidad de registro con super.logDelivery()

Este patrón nos permite mantener el código DRY (Don't Repeat Yourself) mientras personalizamos el comportamiento específico de cada tipo de notificación.

Clases abstractas y métodos abstractos

Las clases abstractas representan un concepto fundamental en la programación orientada a objetos que permite definir una plantilla parcial para otras clases. En TypeScript, estas clases especiales no pueden ser instanciadas directamente y están diseñadas para ser heredadas por clases concretas que completan su implementación.

Definición y propósito

Una clase abstracta se define utilizando la palabra clave abstract y sirve como base conceptual para otras clases. Su principal propósito es:

  • Establecer una estructura común que las subclases deben seguir
  • Definir comportamientos obligatorios mediante métodos abstractos
  • Proporcionar implementaciones parciales que las subclases heredarán
abstract class Database {
  protected connection: string;
  
  constructor(connectionString: string) {
    this.connection = connectionString;
  }
  
  // Método concreto con implementación
  connect(): void {
    console.log(`Connecting to database using: ${this.connection}`);
  }
  
  // Método abstracto sin implementación
  abstract executeQuery(query: string): any[];
  
  // Método abstracto sin implementación
  abstract disconnect(): void;
}

En este ejemplo, Database es una clase abstracta que define la estructura básica para diferentes tipos de bases de datos. Proporciona una implementación para connect(), pero deja los métodos executeQuery() y disconnect() como abstractos, obligando a las subclases a implementarlos.

Métodos abstractos

Los métodos abstractos son declaraciones de métodos sin implementación que deben ser definidos en las clases derivadas. Se declaran con la palabra clave abstract y no contienen un cuerpo de función:

abstract class Shape {
  constructor(protected color: string) {}
  
  // Método concreto
  getColor(): string {
    return this.color;
  }
  
  // Método abstracto
  abstract calculateArea(): number;
  
  // Método abstracto
  abstract calculatePerimeter(): number;
}

Características importantes de los métodos abstractos:

  • No tienen implementación (cuerpo de función)
  • Definen solo la firma del método (nombre, parámetros y tipo de retorno)
  • Obligan a las subclases a proporcionar una implementación
  • Pueden tener cualquier modificador de acceso (public, protected o private)

Implementación de clases abstractas

Para utilizar una clase abstracta, debemos crear una clase derivada que implemente todos los métodos abstractos:

class PostgreSQLDatabase extends Database {
  constructor(connectionString: string) {
    super(connectionString);
  }
  
  // Implementación del método abstracto
  executeQuery(query: string): any[] {
    console.log(`PostgreSQL executing: ${query}`);
    // Lógica específica de PostgreSQL
    return [{ id: 1, name: "Example" }];
  }
  
  // Implementación del método abstracto
  disconnect(): void {
    console.log("Closing PostgreSQL connection");
  }
}

// Uso de la clase concreta
const db = new PostgreSQLDatabase("postgres://localhost:5432/mydb");
db.connect();
const results = db.executeQuery("SELECT * FROM users");
console.log(results);
db.disconnect();

Si una clase derivada no implementa todos los métodos abstractos de su clase base, también debe declararse como abstracta:

// Esta clase sigue siendo abstracta porque no implementa todos los métodos abstractos
abstract class PartialDatabase extends Database {
  executeQuery(query: string): any[] {
    console.log(`Executing query: ${query}`);
    return [];
  }
  
  // No implementa disconnect(), por lo que sigue siendo abstracta
}

Propiedades abstractas

TypeScript también permite definir propiedades abstractas, aunque son menos comunes que los métodos abstractos:

abstract class ConfigProvider {
  // Propiedad abstracta
  abstract readonly apiKey: string;
  
  // Método que utiliza la propiedad abstracta
  getAuthorizationHeader(): string {
    return `Bearer ${this.apiKey}`;
  }
}

class ProductionConfig extends ConfigProvider {
  // Implementación de la propiedad abstracta
  readonly apiKey: string = "prod_key_12345";
}

class DevelopmentConfig extends ConfigProvider {
  // Implementación de la propiedad abstracta
  readonly apiKey: string = "dev_key_abcde";
}

Diferencias entre clases abstractas e interfaces

Aunque las clases abstractas y las interfaces pueden parecer similares, tienen diferencias importantes:

  • Implementación parcial: Las clases abstractas pueden contener implementaciones de métodos, mientras que las interfaces solo definen la estructura.
  • Constructores: Las clases abstractas pueden tener constructores, las interfaces no.
  • Modificadores de acceso: Las clases abstractas permiten modificadores (public, protected, private), las interfaces solo permiten métodos públicos.
  • Herencia múltiple: Una clase puede implementar múltiples interfaces, pero solo puede extender una clase abstracta.
// Interfaz
interface Printable {
  print(): void;
}

// Clase abstracta
abstract class Document {
  constructor(protected title: string) {}
  
  getTitle(): string {
    return this.title;
  }
  
  abstract getContent(): string;
}

// Clase que extiende una clase abstracta e implementa una interfaz
class Report extends Document implements Printable {
  constructor(
    title: string,
    private content: string
  ) {
    super(title);
  }
  
  // Implementación del método abstracto de Document
  getContent(): string {
    return this.content;
  }
  
  // Implementación del método de la interfaz Printable
  print(): void {
    console.log(`Printing report: ${this.title}`);
    console.log(this.content);
  }
}

Ejemplo práctico: Sistema de componentes UI

Veamos un ejemplo más completo que muestra cómo las clases abstractas pueden ser útiles en un sistema de componentes de interfaz de usuario:

abstract class UIComponent {
  protected element: HTMLElement | null = null;
  
  constructor(
    protected id: string,
    protected parent: HTMLElement
  ) {}
  
  // Métodos concretos
  render(): void {
    this.element = this.createElement();
    this.setupEventListeners();
    this.parent.appendChild(this.element);
  }
  
  remove(): void {
    if (this.element && this.element.parentNode) {
      this.element.parentNode.removeChild(this.element);
    }
  }
  
  // Métodos abstractos que las subclases deben implementar
  protected abstract createElement(): HTMLElement;
  protected abstract setupEventListeners(): void;
}

class Button extends UIComponent {
  constructor(
    id: string,
    parent: HTMLElement,
    private label: string,
    private onClick: () => void
  ) {
    super(id, parent);
  }
  
  protected createElement(): HTMLElement {
    const button = document.createElement("button");
    button.id = this.id;
    button.textContent = this.label;
    button.className = "ui-button";
    return button;
  }
  
  protected setupEventListeners(): void {
    if (this.element) {
      this.element.addEventListener("click", this.onClick);
    }
  }
  
  // Método específico de Button
  updateLabel(newLabel: string): void {
    this.label = newLabel;
    if (this.element) {
      this.element.textContent = newLabel;
    }
  }
}

class TextField extends UIComponent {
  private inputElement: HTMLInputElement | null = null;
  
  constructor(
    id: string,
    parent: HTMLElement,
    private placeholder: string,
    private onInput: (value: string) => void
  ) {
    super(id, parent);
  }
  
  protected createElement(): HTMLElement {
    const container = document.createElement("div");
    container.className = "text-field-container";
    
    this.inputElement = document.createElement("input");
    this.inputElement.id = this.id;
    this.inputElement.type = "text";
    this.inputElement.placeholder = this.placeholder;
    this.inputElement.className = "ui-text-field";
    
    container.appendChild(this.inputElement);
    return container;
  }
  
  protected setupEventListeners(): void {
    if (this.inputElement) {
      this.inputElement.addEventListener("input", (e) => {
        const target = e.target as HTMLInputElement;
        this.onInput(target.value);
      });
    }
  }
  
  // Método específico de TextField
  getValue(): string {
    return this.inputElement ? this.inputElement.value : "";
  }
  
  setValue(value: string): void {
    if (this.inputElement) {
      this.inputElement.value = value;
    }
  }
}

// Uso del sistema de componentes
document.addEventListener("DOMContentLoaded", () => {
  const container = document.getElementById("app") as HTMLElement;
  
  // Crear un campo de texto
  const nameField = new TextField(
    "name-field",
    container,
    "Enter your name",
    (value) => console.log(`Name updated: ${value}`)
  );
  nameField.render();
  
  // Crear un botón
  const submitButton = new Button(
    "submit-button",
    container,
    "Submit",
    () => {
      const name = nameField.getValue();
      alert(`Hello, ${name}!`);
    }
  );
  submitButton.render();
});

En este ejemplo, UIComponent es una clase abstracta que define la estructura básica para todos los componentes de interfaz de usuario. Proporciona implementaciones para los métodos comunes como render() y remove(), pero deja los detalles específicos como createElement() y setupEventListeners() como métodos abstractos.

Las clases concretas Button y TextField extienden UIComponent e implementan los métodos abstractos con su comportamiento específico. Este enfoque permite:

  1. Reutilización de código: La lógica común está en la clase abstracta
  2. Consistencia: Todos los componentes siguen la misma estructura básica
  3. Extensibilidad: Es fácil añadir nuevos tipos de componentes
  4. Polimorfismo: Podemos tratar diferentes componentes de manera uniforme

Cuándo usar clases abstractas

Las clases abstractas son especialmente útiles cuando:

  • Necesitas compartir código y estructura entre varias clases relacionadas
  • Quieres definir una plantilla con implementación parcial
  • Deseas establecer un contrato que las subclases deben cumplir
  • Tienes una jerarquía de clases con comportamientos comunes

En contraste, considera usar interfaces cuando:

  • Solo necesitas definir un contrato sin implementación
  • Quieres permitir la implementación múltiple
  • Estás definiendo un tipo más que un comportamiento

Implementación y sobrescritura de métodos

La implementación y sobrescritura de métodos son técnicas fundamentales en la programación orientada a objetos que permiten personalizar el comportamiento de las clases derivadas. En TypeScript, estas técnicas nos ayudan a adaptar y extender la funcionalidad heredada de las clases base, permitiendo que las subclases proporcionen su propia implementación específica.

Implementación de métodos abstractos

Cuando una clase extiende una clase abstracta, debe proporcionar implementaciones concretas para todos los métodos abstractos definidos en la clase base:

abstract class PaymentProcessor {
  constructor(protected amount: number) {}
  
  // Método concreto que todas las subclases heredan
  getAmount(): number {
    return this.amount;
  }
  
  // Método abstracto que las subclases deben implementar
  abstract process(): boolean;
  
  // Método abstracto con parámetros
  abstract generateReceipt(id: string): string;
}

class CreditCardProcessor extends PaymentProcessor {
  constructor(
    amount: number,
    private cardNumber: string,
    private expiryDate: string
  ) {
    super(amount);
  }
  
  // Implementación del método abstracto process
  process(): boolean {
    console.log(`Processing credit card payment of $${this.amount}`);
    console.log(`Using card: ${this.maskCardNumber()}`);
    // Lógica de procesamiento real iría aquí
    return true;
  }
  
  // Implementación del método abstracto generateReceipt
  generateReceipt(id: string): string {
    return `
      Receipt ID: ${id}
      Amount: $${this.amount}
      Payment Method: Credit Card (${this.maskCardNumber()})
      Date: ${new Date().toISOString()}
    `;
  }
  
  // Método específico de esta clase
  private maskCardNumber(): string {
    const lastFour = this.cardNumber.slice(-4);
    return `****-****-****-${lastFour}`;
  }
}

Al implementar métodos abstractos, es importante:

  • Mantener la misma firma (nombre, parámetros y tipo de retorno)
  • Proporcionar una implementación completa del método
  • Asegurarse de que la implementación cumpla con el propósito del método abstracto

Sobrescritura de métodos

La sobrescritura (override) ocurre cuando una clase derivada proporciona una implementación específica para un método que ya está definido en su clase base:

class Vehicle {
  constructor(protected make: string, protected model: string) {}
  
  getInfo(): string {
    return `${this.make} ${this.model}`;
  }
  
  startEngine(): void {
    console.log("Engine started");
  }
}

class ElectricVehicle extends Vehicle {
  constructor(
    make: string,
    model: string,
    private batteryCapacity: number
  ) {
    super(make, model);
  }
  
  // Sobrescribimos el método getInfo
  getInfo(): string {
    return `${super.getInfo()} - Electric (${this.batteryCapacity} kWh)`;
  }
  
  // Sobrescribimos el método startEngine
  startEngine(): void {
    console.log("Initializing electric motors");
    super.startEngine(); // Llamamos al método de la clase base
  }
  
  // Método específico de esta clase
  chargeBattery(amount: number): void {
    console.log(`Charging battery with ${amount} kWh`);
  }
}

const teslaModelS = new ElectricVehicle("Tesla", "Model S", 100);
console.log(teslaModelS.getInfo()); // Tesla Model S - Electric (100 kWh)
teslaModelS.startEngine(); // Imprime: Initializing electric motors, Engine started

Uso del modificador override

TypeScript 4.3+ introdujo el modificador override que permite indicar explícitamente que un método está sobrescribiendo un método de la clase base:

class Animal {
  makeSound(): string {
    return "Some generic sound";
  }
  
  eat(food: string): void {
    console.log(`Eating ${food}`);
  }
}

class Dog extends Animal {
  // Usando el modificador override
  override makeSound(): string {
    return "Woof!";
  }
  
  override eat(food: string): void {
    if (food === "chocolate") {
      console.log("Dogs shouldn't eat chocolate!");
    } else {
      super.eat(food);
    }
  }
  
  fetch(): void {
    console.log("Fetching the ball!");
  }
}

Ventajas de usar el modificador override:

  • Claridad: Indica explícitamente la intención de sobrescribir un método
  • Seguridad: TypeScript verificará que realmente exista un método con ese nombre en la clase base
  • Mantenimiento: Ayuda a detectar errores si el método base cambia o se elimina

Para habilitar la verificación estricta de sobrescrituras, puedes configurar la opción noImplicitOverride en tu tsconfig.json:

{
  "compilerOptions": {
    "noImplicitOverride": true
  }
}

Sobrescritura vs. sobrecarga de métodos

Es importante distinguir entre sobrescritura y sobrecarga de métodos:

  • Sobrescritura: Proporcionar una nueva implementación para un método heredado (mismo nombre y firma)
  • Sobrecarga: Definir múltiples métodos con el mismo nombre pero diferentes parámetros
class Calculator {
  // Método original
  add(a: number, b: number): number {
    return a + b;
  }
}

class ScientificCalculator extends Calculator {
  // Sobrescritura: misma firma, diferente implementación
  override add(a: number, b: number): number {
    // Redondeo a 10 decimales para mayor precisión
    return parseFloat((a + b).toFixed(10));
  }
  
  // Sobrecarga: mismo nombre, diferentes parámetros
  add(a: number, b: number, c: number): number;
  add(a: number, b: number, c?: number): number {
    if (c !== undefined) {
      return this.add(this.add(a, b), c);
    }
    return super.add(a, b);
  }
}

Patrones comunes de implementación y sobrescritura

1. Extensión del comportamiento base

Un patrón común es extender el comportamiento del método base añadiendo funcionalidad adicional:

class Logger {
  log(message: string): void {
    console.log(`LOG: ${message}`);
  }
}

class TimestampLogger extends Logger {
  override log(message: string): void {
    const timestamp = new Date().toISOString();
    super.log(`[${timestamp}] ${message}`);
  }
}

const logger = new TimestampLogger();
logger.log("User logged in"); // LOG: [2023-08-15T10:30:45.123Z] User logged in

2. Especialización del comportamiento

Otro patrón es especializar el comportamiento para casos específicos:

class ShapeRenderer {
  render(width: number, height: number): void {
    console.log(`Rendering a shape of ${width}x${height}`);
  }
}

class CircleRenderer extends ShapeRenderer {
  override render(width: number, height: number): void {
    if (width !== height) {
      console.log("Warning: Circle should have equal width and height");
    }
    const radius = width / 2;
    console.log(`Rendering a circle with radius ${radius}`);
  }
}

3. Filtrado o validación

Las subclases pueden filtrar o validar entradas antes de procesarlas:

class UserService {
  createUser(username: string, email: string): boolean {
    console.log(`Creating user: ${username} (${email})`);
    // Lógica para crear usuario
    return true;
  }
}

class EnhancedUserService extends UserService {
  override createUser(username: string, email: string): boolean {
    // Validación de email
    if (!email.includes('@')) {
      console.error("Invalid email format");
      return false;
    }
    
    // Validación de nombre de usuario
    if (username.length < 3) {
      console.error("Username too short");
      return false;
    }
    
    // Si pasa las validaciones, llamamos al método base
    return super.createUser(username, email);
  }
}

Ejemplo práctico: Sistema de renderizado de componentes

Veamos un ejemplo más completo que muestra diferentes técnicas de implementación y sobrescritura:

abstract class Component {
  constructor(protected id: string) {}
  
  // Método concreto
  getId(): string {
    return this.id;
  }
  
  // Método que puede ser sobrescrito
  render(): string {
    return `<div id="${this.id}"></div>`;
  }
  
  // Método abstracto
  abstract getType(): string;
}

class TextComponent extends Component {
  constructor(
    id: string,
    private text: string,
    private size: 'small' | 'medium' | 'large' = 'medium'
  ) {
    super(id);
  }
  
  // Implementación del método abstracto
  getType(): string {
    return "text";
  }
  
  // Sobrescritura del método render
  override render(): string {
    const sizeClass = `text-${this.size}`;
    return `<p id="${this.id}" class="${sizeClass}">${this.text}</p>`;
  }
  
  // Método específico
  setText(newText: string): void {
    this.text = newText;
  }
}

class ButtonComponent extends Component {
  constructor(
    id: string,
    private label: string,
    private type: 'primary' | 'secondary' | 'danger' = 'primary'
  ) {
    super(id);
  }
  
  // Implementación del método abstracto
  getType(): string {
    return "button";
  }
  
  // Sobrescritura del método render
  override render(): string {
    return `<button id="${this.id}" class="btn btn-${this.type}">${this.label}</button>`;
  }
}

class ImageComponent extends Component {
  constructor(
    id: string,
    private src: string,
    private alt: string,
    private width?: number,
    private height?: number
  ) {
    super(id);
  }
  
  // Implementación del método abstracto
  getType(): string {
    return "image";
  }
  
  // Sobrescritura del método render
  override render(): string {
    let attributes = `id="${this.id}" src="${this.src}" alt="${this.alt}"`;
    
    if (this.width) {
      attributes += ` width="${this.width}"`;
    }
    
    if (this.height) {
      attributes += ` height="${this.height}"`;
    }
    
    return `<img ${attributes} />`;
  }
}

// Clase que utiliza los componentes
class ComponentRenderer {
  private components: Component[] = [];
  
  addComponent(component: Component): void {
    this.components.push(component);
  }
  
  renderAll(): string {
    return this.components
      .map(component => {
        return `<!-- ${component.getType().toUpperCase()} Component: ${component.getId()} -->\n${component.render()}`;
      })
      .join('\n\n');
  }
}

// Uso del sistema
const renderer = new ComponentRenderer();

renderer.addComponent(new TextComponent("welcome-text", "Welcome to our website!", "large"));
renderer.addComponent(new ButtonComponent("login-btn", "Log In", "primary"));
renderer.addComponent(new ImageComponent("logo", "/images/logo.png", "Company Logo", 200, 80));

const html = renderer.renderAll();
console.log(html);

Este ejemplo muestra:

  1. Implementación de métodos abstractos: Cada componente implementa getType()
  2. Sobrescritura de métodos: Cada componente sobrescribe render() con su implementación específica
  3. Polimorfismo: ComponentRenderer trata a todos los componentes de manera uniforme a través de la clase base
  4. Extensibilidad: Es fácil añadir nuevos tipos de componentes sin modificar el código existente

Mejores prácticas

Al implementar y sobrescribir métodos, considera estas mejores prácticas:

  • Usa el modificador override para indicar explícitamente la sobrescritura
  • Mantén el contrato de la clase base (tipos de parámetros y retorno)
  • Llama a super cuando necesites extender el comportamiento base en lugar de reemplazarlo completamente
  • No cambies la semántica del método original; la sobrescritura debe respetar el propósito original
  • Documenta cualquier cambio significativo en el comportamiento
  • Evita sobrescribir métodos privados (técnicamente no es posible en TypeScript, pero es una buena práctica conceptual)

La implementación y sobrescritura de métodos son herramientas poderosas que, cuando se utilizan correctamente, permiten crear jerarquías de clases flexibles y extensibles que pueden adaptarse a diferentes requisitos mientras mantienen una estructura coherente.

Aprende TypeScript online

Otros ejercicios de programación de TypeScript

Evalúa tus conocimientos de esta lección Herencia y clases abstractas 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

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

Proyecto Inventario de productos

TypeScript
Proyecto

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 Y Efectos Secundarios

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 Y Tipos Condicionales

TypeScript

Tipos Intermedios Y Avanzados

Tipos Genéricos Básicos

TypeScript

Tipos Intermedios Y Avanzados

Tipos De Unión E Intersección

TypeScript

Tipos Intermedios Y Avanzados

Tipos De Utilidad (Partial, Required, Pick, Etc)

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

  1. Comprender el concepto de herencia en la programación orientada a objetos.
  2. Conocer el uso de la palabra clave extends para establecer una relación de herencia entre clases en TypeScript.
  3. Aprender cómo una clase hija puede heredar propiedades y métodos de una clase padre.
  4. Entender que las subclases pueden agregar propiedades y métodos adicionales o modificar el comportamiento heredado de la clase base.
  5. Practicar la creación de clases base y subclases en TypeScript y su utilización para reutilizar y organizar el código de manera eficiente.
  6. Familiarizarse con los beneficios de la herencia, como la creación de jerarquías de clases y la simplificación de la estructura de código.