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ícateExtendiendo 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 usarthis
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:
- Llamar al constructor de la clase padre y pasar los parámetros necesarios
- Extender el comportamiento del método
formatMessage()
en lugar de reemplazarlo completamente - 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
oprivate
)
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:
- Reutilización de código: La lógica común está en la clase abstracta
- Consistencia: Todos los componentes siguen la misma estructura básica
- Extensibilidad: Es fácil añadir nuevos tipos de componentes
- 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:
- Implementación de métodos abstractos: Cada componente implementa
getType()
- Sobrescritura de métodos: Cada componente sobrescribe
render()
con su implementación específica - Polimorfismo:
ComponentRenderer
trata a todos los componentes de manera uniforme a través de la clase base - 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.
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
Reto composición de funciones
Reto tipos especiales
Reto tipos genéricos
Módulos
Polimorfismo
Funciones TypeScript
Interfaces
Funciones puras
Reto namespaces
Funciones flecha
Polimorfismo
Operadores
Conversor de unidades
Funciones flecha
Control de flujo
Herencia
Clases
Proyecto validación de tipado
Clases y objetos
Encapsulación
Herencia
Proyecto sistema de votación
Reto genéricos con clases
Inmutabilidad
Interfaces
Funciones de alto orden
Reto map y filter
Control de flujo
Interfaces
Reto funciones orden superior
Herencia y clases abstractas
Reto tipos mapped
Herencia de clases
Reto funciones puras
Variables y constantes
Introducción a TypeScript
Reto testing unitario
Funciones de primera clase
Clases
OOP y CRUD en TypeScript
Interfaces y su implementación
Tipos genéricos
Namespaces
Operadores y expresiones
Proyecto generador de contraseñas
Reto unión e intersección
Encapsulación
Tipos de unión e intersección
Tipos de unión e intersección
Reto hola mundo en TS
Variables y constantes
Funciones puras
Control de flujo
Introducción a TypeScript
Resolución de módulos
Control de flujo
Reto tipos de utilidad
Reto tipos literales y condicionales
Reto exportar e importar
Propiedades y métodos
Tipos de utilidad
Clases y objetos
Tipos de datos, variables y constantes
Proyecto Minigestor de tareas
Operadores
Funciones flecha y contexto
Proyecto Inventario de productos
Funciones
Reto type aliases
Funciones de alto orden
Funciones y parámetros tipados
Tipos literales
Reto enums
Tipos de utilidad
Modificadores de acceso y encapsulación
Polimorfismo
Tipos genéricos
Reto módulos
Tipos literales
Inmutabilidad
Proyecto Generator de datos
Variables y constantes
Funciones de primera clase
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
Introducción Y Entorno
Instalación Y Configuración De Typescript
Introducción Y Entorno
Tipos De Datos, Variables Y Constantes
Sintaxis
Operadores Y Expresiones
Sintaxis
Control De Flujo
Sintaxis
Funciones Y Parámetros Tipados
Sintaxis
Funciones Flecha Y Contexto
Sintaxis
Enums
Sintaxis
Type Aliases Y Aserciones De Tipo
Sintaxis
Clases Y Objetos
Programación Orientada A Objetos
Interfaces Y Su Implementación
Programación Orientada A Objetos
Modificadores De Acceso Y Encapsulación
Programación Orientada A Objetos
Herencia Y Clases Abstractas
Programación Orientada A Objetos
Polimorfismo
Programación Orientada A Objetos
Decoradores Básicos
Programación Orientada A Objetos
Propiedades Y Métodos
Programación Orientada A Objetos
Inmutabilidad
Programación Funcional
Funciones Puras Y Efectos Secundarios
Programación Funcional
Funciones De Primera Clase
Programación Funcional
Funciones De Alto Orden
Programación Funcional
Conceptos Básicos E Inmutabilidad
Programación Funcional
Funciones De Primera Clase Y Orden Superior
Programación Funcional
Composición De Funciones
Programación Funcional
Métodos Funcionales De Arrays (Map, Filter, Reduce)
Programación Funcional
Tipos Literales Y Tipos Condicionales
Tipos Intermedios Y Avanzados
Tipos Genéricos Básicos
Tipos Intermedios Y Avanzados
Tipos De Unión E Intersección
Tipos Intermedios Y Avanzados
Tipos De Utilidad (Partial, Required, Pick, Etc)
Tipos Intermedios Y Avanzados
Unknown, Never Y Tipos Especiales
Tipos Intermedios Y Avanzados
Tipos Mapped
Tipos Intermedios Y Avanzados
Genéricos Con Clases E Interfaces
Tipos Intermedios Y Avanzados
Módulos
Namespaces Y Módulos
Namespaces
Namespaces Y Módulos
Resolución De Módulos
Namespaces Y Módulos
Exportación E Importación De Módulos
Namespaces Y Módulos
Introducción A Módulos
Namespaces Y Módulos
Testing Unitario En Typescript
Testing
En esta lección
Objetivos de aprendizaje de esta lección
- Comprender el concepto de herencia en la programación orientada a objetos.
- Conocer el uso de la palabra clave
extends
para establecer una relación de herencia entre clases en TypeScript. - Aprender cómo una clase hija puede heredar propiedades y métodos de una clase padre.
- Entender que las subclases pueden agregar propiedades y métodos adicionales o modificar el comportamiento heredado de la clase base.
- 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.
- 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.