TypeScript
Tutorial TypeScript: Genéricos con clases e interfaces
Aprende a usar genéricos con clases e interfaces en TypeScript para crear código flexible y seguro con ejemplos prácticos y avanzados.
Aprende TypeScript y certifícateClases genéricas
Las clases genéricas en TypeScript permiten crear componentes reutilizables que pueden trabajar con diferentes tipos de datos, manteniendo la seguridad de tipos. Esta característica es fundamental para escribir código flexible y reutilizable sin sacrificar la verificación de tipos en tiempo de compilación.
Una clase genérica se define utilizando el parámetro de tipo entre símbolos de menor y mayor <T>
, donde T
es un nombre de tipo que actúa como un marcador de posición. Este marcador será reemplazado por un tipo real cuando se instancie la clase.
Sintaxis básica
La sintaxis para definir una clase genérica es la siguiente:
class Contenedor<T> {
private valor: T;
constructor(valor: T) {
this.valor = valor;
}
obtenerValor(): T {
return this.valor;
}
establecerValor(nuevoValor: T): void {
this.valor = nuevoValor;
}
}
En este ejemplo, T
es un parámetro de tipo que puede ser reemplazado por cualquier tipo cuando se crea una instancia de la clase Contenedor
. Esto permite crear contenedores para diferentes tipos de datos:
// Contenedor para números
const contenedorNumero = new Contenedor<number>(123);
const numero = contenedorNumero.obtenerValor(); // tipo: number
// Contenedor para cadenas
const contenedorTexto = new Contenedor<string>("Hola mundo");
const texto = contenedorTexto.obtenerValor(); // tipo: string
// Contenedor para objetos personalizados
interface Usuario {
id: number;
nombre: string;
}
const usuario: Usuario = { id: 1, nombre: "Ana" };
const contenedorUsuario = new Contenedor<Usuario>(usuario);
const usuarioRecuperado = contenedorUsuario.obtenerValor(); // tipo: Usuario
Múltiples parámetros de tipo
Las clases genéricas pueden utilizar múltiples parámetros de tipo cuando necesitamos trabajar con diferentes tipos en la misma clase:
class Par<K, V> {
private clave: K;
private valor: V;
constructor(clave: K, valor: V) {
this.clave = clave;
this.valor = valor;
}
getClave(): K {
return this.clave;
}
getValor(): V {
return this.valor;
}
}
// Uso con diferentes combinaciones de tipos
const parStringNumber = new Par<string, number>("edad", 30);
const parNumberBoolean = new Par<number, boolean>(1, true);
Esta flexibilidad permite crear estructuras de datos complejas que mantienen la seguridad de tipos para diferentes combinaciones de datos.
Restricciones en tipos genéricos
A veces necesitamos limitar los tipos que pueden utilizarse como argumentos genéricos. Para esto, utilizamos la palabra clave extends
para establecer restricciones sobre los parámetros de tipo:
interface ConNombre {
nombre: string;
}
class RegistroNombres<T extends ConNombre> {
private elementos: T[] = [];
agregar(elemento: T): void {
this.elementos.push(elemento);
}
mostrarNombres(): void {
this.elementos.forEach(elemento => {
console.log(elemento.nombre);
});
}
}
// Tipos válidos que cumplen con la restricción
interface Estudiante extends ConNombre {
curso: string;
}
interface Empleado extends ConNombre {
departamento: string;
}
const registroEstudiantes = new RegistroNombres<Estudiante>();
registroEstudiantes.agregar({ nombre: "Carlos", curso: "TypeScript" });
const registroEmpleados = new RegistroNombres<Empleado>();
registroEmpleados.agregar({ nombre: "Laura", departamento: "Desarrollo" });
// Esto generaría un error de compilación
// const registroInvalido = new RegistroNombres<number>();
En este ejemplo, el parámetro T
está restringido a tipos que implementen la interfaz ConNombre
, lo que garantiza que todos los elementos agregados al registro tengan una propiedad nombre
.
Parámetros de tipo con valores predeterminados
TypeScript permite especificar valores predeterminados para los parámetros de tipo genéricos:
class ColeccionConfigurable<T, U = T> {
private items: T[] = [];
private defaultValue: U;
constructor(defaultValue: U) {
this.defaultValue = defaultValue;
}
agregar(item: T): void {
this.items.push(item);
}
obtenerPrimero(): T | U {
return this.items.length > 0 ? this.items[0] : this.defaultValue;
}
}
// El segundo tipo toma el valor del primero por defecto
const coleccionNumeros = new ColeccionConfigurable<number>(0);
// Especificando ambos tipos explícitamente
const coleccionTextos = new ColeccionConfigurable<string, string>("(vacío)");
// Usando tipos diferentes
const coleccionConDefault = new ColeccionConfigurable<string, boolean>(false);
En este ejemplo, si no se proporciona el segundo parámetro de tipo U
, tomará el mismo tipo que T
.
Métodos genéricos dentro de clases genéricas
Una clase genérica también puede contener métodos genéricos que introduzcan sus propios parámetros de tipo:
class Utilidades<T> {
private valor: T;
constructor(valor: T) {
this.valor = valor;
}
// Método genérico dentro de una clase genérica
intercambiar<U>(otro: U): [U, T] {
return [otro, this.valor];
}
// Otro método genérico con restricción
combinar<U extends object>(otro: U): T & U {
return { ...this.valor as object, ...otro } as T & U;
}
}
const utilString = new Utilidades<string>("hola");
const [num, str] = utilString.intercambiar(42); // num: number, str: string
const utilObj = new Utilidades<{ id: number }>({ id: 1 });
const combinado = utilObj.combinar({ nombre: "Producto" });
// combinado tiene tipo: { id: number } & { nombre: string }
Esta técnica permite crear métodos muy flexibles que pueden trabajar con diferentes tipos, incluso dentro del contexto de una clase genérica.
Ejemplo práctico: implementación de una cola genérica
Veamos un ejemplo más completo de una clase genérica que implementa una estructura de datos de cola (FIFO - First In, First Out):
class Cola<T> {
private elementos: T[] = [];
// Añade un elemento al final de la cola
encolar(elemento: T): void {
this.elementos.push(elemento);
}
// Remueve y devuelve el primer elemento
desencolar(): T | undefined {
if (this.estaVacia()) {
return undefined;
}
return this.elementos.shift();
}
// Devuelve el primer elemento sin removerlo
frente(): T | undefined {
if (this.estaVacia()) {
return undefined;
}
return this.elementos[0];
}
// Verifica si la cola está vacía
estaVacia(): boolean {
return this.elementos.length === 0;
}
// Devuelve el tamaño de la cola
tamaño(): number {
return this.elementos.length;
}
// Limpia la cola
limpiar(): void {
this.elementos = [];
}
}
// Cola de números
const colaNumeros = new Cola<number>();
colaNumeros.encolar(1);
colaNumeros.encolar(2);
colaNumeros.encolar(3);
console.log(colaNumeros.desencolar()); // 1
console.log(colaNumeros.frente()); // 2
console.log(colaNumeros.tamaño()); // 2
// Cola de objetos personalizados
interface Tarea {
id: number;
descripcion: string;
prioridad: number;
}
const colaTareas = new Cola<Tarea>();
colaTareas.encolar({ id: 1, descripcion: "Completar informe", prioridad: 2 });
colaTareas.encolar({ id: 2, descripcion: "Revisar código", prioridad: 1 });
const siguienteTarea = colaTareas.desencolar();
if (siguienteTarea) {
console.log(`Tarea a realizar: ${siguienteTarea.descripcion}`);
}
Esta implementación de cola puede trabajar con cualquier tipo de datos, desde tipos primitivos hasta objetos complejos, manteniendo la seguridad de tipos en todo momento.
Clases genéricas con propiedades estáticas
Es importante entender que los miembros estáticos de una clase genérica no pueden usar los parámetros de tipo de la clase, ya que estos miembros son compartidos entre todas las instancias:
class FactoriaGenerica<T> {
// Error: Los miembros estáticos no pueden referenciar parámetros de tipo
// static crearInstancia(): T {
// return new T();
// }
// Esto es válido: método de instancia usando el tipo genérico
crearCopia(original: T): T {
return { ...original as object } as T;
}
// Los miembros estáticos pueden tener sus propios parámetros genéricos
static crear<U>(valor: U): U {
return valor;
}
}
const factoria = new FactoriaGenerica<string>();
const copia = factoria.crearCopia("original");
// Usando el método estático genérico
const nuevoValor = FactoriaGenerica.crear({ id: 1, nombre: "Producto" });
Esta limitación existe porque los parámetros de tipo genérico solo están disponibles cuando se crea una instancia de la clase.
Interfaces genéricas
Las interfaces genéricas en TypeScript representan una herramienta fundamental para definir contratos que pueden trabajar con diferentes tipos de datos. A diferencia de las clases genéricas, las interfaces genéricas se centran exclusivamente en la definición de estructuras y comportamientos sin implementación.
La sintaxis para crear una interfaz genérica es similar a la de las clases genéricas, utilizando los símbolos <>
para declarar los parámetros de tipo:
interface Respuesta<T> {
datos: T;
estado: number;
mensaje: string;
}
Esta interfaz Respuesta<T>
puede adaptarse a diferentes tipos de datos según las necesidades de la aplicación:
// Respuesta con datos de usuario
interface Usuario {
id: number;
nombre: string;
email: string;
}
const respuestaUsuario: Respuesta<Usuario> = {
datos: { id: 1, nombre: "Ana García", email: "ana@ejemplo.com" },
estado: 200,
mensaje: "Usuario recuperado correctamente"
};
// Respuesta con lista de productos
interface Producto {
id: string;
nombre: string;
precio: number;
}
const respuestaProductos: Respuesta<Producto[]> = {
datos: [
{ id: "p1", nombre: "Teclado", precio: 49.99 },
{ id: "p2", nombre: "Ratón", precio: 29.99 }
],
estado: 200,
mensaje: "Productos recuperados correctamente"
};
Interfaces genéricas para funciones
Una característica particularmente útil es la capacidad de definir interfaces para funciones con parámetros genéricos:
interface Transformador<T, U> {
(entrada: T): U;
}
// Implementación de la interfaz genérica de función
const convertirAString: Transformador<number, string> = (numero) => {
return numero.toString();
};
const numeroComoTexto = convertirAString(42); // "42"
// Otra implementación con diferentes tipos
const obtenerLongitud: Transformador<any[], number> = (array) => {
return array.length;
};
const longitud = obtenerLongitud([1, 2, 3, 4]); // 4
Esta capacidad permite definir contratos para funciones que mantienen la seguridad de tipos mientras proporcionan flexibilidad en los tipos de entrada y salida.
Interfaces genéricas para métodos
Las interfaces genéricas también pueden definir métodos que utilicen parámetros de tipo:
interface Coleccion<T> {
agregar(item: T): void;
obtener(indice: number): T;
buscar(condicion: (item: T) => boolean): T | undefined;
tamaño(): number;
}
// Implementación de la interfaz genérica
class ListaArray<T> implements Coleccion<T> {
private items: T[] = [];
agregar(item: T): void {
this.items.push(item);
}
obtener(indice: number): T {
if (indice < 0 || indice >= this.items.length) {
throw new Error("Índice fuera de rango");
}
return this.items[indice];
}
buscar(condicion: (item: T) => boolean): T | undefined {
return this.items.find(condicion);
}
tamaño(): number {
return this.items.length;
}
}
// Uso de la implementación
const listaNumeros = new ListaArray<number>();
listaNumeros.agregar(10);
listaNumeros.agregar(20);
listaNumeros.agregar(30);
const numeroEncontrado = listaNumeros.buscar(num => num > 15); // 20
En este ejemplo, la interfaz Coleccion<T>
define un contrato para estructuras de datos que pueden almacenar y manipular elementos de cualquier tipo, y la clase ListaArray<T>
proporciona una implementación concreta.
Interfaces genéricas con múltiples parámetros
Al igual que las clases, las interfaces genéricas pueden utilizar múltiples parámetros de tipo para representar relaciones más complejas:
interface Diccionario<K extends string | number | symbol, V> {
establecer(clave: K, valor: V): void;
obtener(clave: K): V | undefined;
eliminar(clave: K): boolean;
contieneClave(clave: K): boolean;
obtenerClaves(): K[];
}
class MapaDiccionario<K extends string | number | symbol, V> implements Diccionario<K, V> {
private elementos = new Map<K, V>();
establecer(clave: K, valor: V): void {
this.elementos.set(clave, valor);
}
obtener(clave: K): V | undefined {
return this.elementos.get(clave);
}
eliminar(clave: K): boolean {
return this.elementos.delete(clave);
}
contieneClave(clave: K): boolean {
return this.elementos.has(clave);
}
obtenerClaves(): K[] {
return Array.from(this.elementos.keys());
}
}
// Uso con diferentes tipos
const diccionarioStringNumero = new MapaDiccionario<string, number>();
diccionarioStringNumero.establecer("uno", 1);
diccionarioStringNumero.establecer("dos", 2);
console.log(diccionarioStringNumero.obtener("uno")); // 1
const diccionarioNumeroString = new MapaDiccionario<number, string>();
diccionarioNumeroString.establecer(1, "uno");
diccionarioNumeroString.establecer(2, "dos");
console.log(diccionarioNumeroString.obtener(1)); // "uno"
En este ejemplo, la interfaz Diccionario<K, V>
utiliza dos parámetros de tipo: K
para las claves (restringido a tipos que pueden ser claves de objetos) y V
para los valores.
Interfaces genéricas con restricciones
Las interfaces genéricas pueden utilizar restricciones para limitar los tipos que pueden utilizarse como argumentos:
interface ConIdentificador {
id: number | string;
}
interface Repositorio<T extends ConIdentificador> {
guardar(item: T): T;
obtenerPorId(id: number | string): T | null;
actualizar(item: T): boolean;
eliminar(id: number | string): boolean;
listarTodos(): T[];
}
// Implementación para un tipo específico
interface Empleado extends ConIdentificador {
id: number;
nombre: string;
departamento: string;
}
class RepositorioEmpleados implements Repositorio<Empleado> {
private empleados: Empleado[] = [];
guardar(empleado: Empleado): Empleado {
this.empleados.push({...empleado});
return empleado;
}
obtenerPorId(id: number | string): Empleado | null {
const empleado = this.empleados.find(e => e.id === id);
return empleado ? {...empleado} : null;
}
actualizar(empleado: Empleado): boolean {
const indice = this.empleados.findIndex(e => e.id === empleado.id);
if (indice >= 0) {
this.empleados[indice] = {...empleado};
return true;
}
return false;
}
eliminar(id: number | string): boolean {
const indiceInicial = this.empleados.length;
this.empleados = this.empleados.filter(e => e.id !== id);
return this.empleados.length < indiceInicial;
}
listarTodos(): Empleado[] {
return [...this.empleados];
}
}
En este ejemplo, la interfaz Repositorio<T>
está restringida a tipos que implementen la interfaz ConIdentificador
, garantizando que todos los elementos tengan una propiedad id
.
Interfaces genéricas con tipos indexados
Las interfaces genéricas pueden combinarse con tipos indexados para crear estructuras de datos flexibles:
interface ObjetoIndexado<T> {
[clave: string]: T;
}
function obtenerValores<T>(objeto: ObjetoIndexado<T>): T[] {
return Object.values(objeto);
}
const datosNumericos: ObjetoIndexado<number> = {
a: 1,
b: 2,
c: 3
};
const valoresNumericos = obtenerValores(datosNumericos); // [1, 2, 3]
const datosTexto: ObjetoIndexado<string> = {
nombre: "Juan",
apellido: "Pérez",
profesion: "Desarrollador"
};
const valoresTexto = obtenerValores(datosTexto); // ["Juan", "Pérez", "Desarrollador"]
Esta técnica es especialmente útil para trabajar con objetos dinámicos donde las claves no se conocen de antemano.
Interfaces genéricas anidadas
Las interfaces genéricas pueden anidarse para representar estructuras de datos más complejas:
interface Resultado<T> {
exito: boolean;
datos?: T;
error?: string;
}
interface Paginado<T> {
items: T[];
total: number;
pagina: number;
porPagina: number;
}
// Combinando interfaces genéricas
interface RespuestaPaginada<T> extends Resultado<Paginado<T>> {
tiempoRespuesta: number;
}
// Uso con un tipo específico
interface Articulo {
id: number;
titulo: string;
contenido: string;
}
const respuestaArticulos: RespuestaPaginada<Articulo> = {
exito: true,
datos: {
items: [
{ id: 1, titulo: "Introducción a TypeScript", contenido: "TypeScript es un superconjunto de JavaScript..." },
{ id: 2, titulo: "Interfaces genéricas", contenido: "Las interfaces genéricas permiten..." }
],
total: 45,
pagina: 1,
porPagina: 2
},
tiempoRespuesta: 120 // milisegundos
};
Esta capacidad de anidar interfaces genéricas permite crear estructuras de datos complejas y reutilizables que mantienen la seguridad de tipos.
Interfaces genéricas para patrones de diseño
Las interfaces genéricas son particularmente útiles para implementar patrones de diseño como el patrón Observer:
interface Observable<T> {
suscribir(observador: Observador<T>): void;
desuscribir(observador: Observador<T>): void;
notificar(datos: T): void;
}
interface Observador<T> {
actualizar(datos: T): void;
}
// Implementación del patrón Observer
class Publicador<T> implements Observable<T> {
private observadores: Observador<T>[] = [];
suscribir(observador: Observador<T>): void {
if (!this.observadores.includes(observador)) {
this.observadores.push(observador);
}
}
desuscribir(observador: Observador<T>): void {
const indice = this.observadores.indexOf(observador);
if (indice !== -1) {
this.observadores.splice(indice, 1);
}
}
notificar(datos: T): void {
for (const observador of this.observadores) {
observador.actualizar(datos);
}
}
}
// Implementación de un observador concreto
class SuscriptorConsola<T> implements Observador<T> {
private nombre: string;
constructor(nombre: string) {
this.nombre = nombre;
}
actualizar(datos: T): void {
console.log(`${this.nombre} recibió:`, datos);
}
}
// Uso del patrón
const publicadorNumeros = new Publicador<number>();
const suscriptor1 = new SuscriptorConsola<number>("Observador 1");
const suscriptor2 = new SuscriptorConsola<number>("Observador 2");
publicadorNumeros.suscribir(suscriptor1);
publicadorNumeros.suscribir(suscriptor2);
publicadorNumeros.notificar(42);
// Salida:
// Observador 1 recibió: 42
// Observador 2 recibió: 42
Este ejemplo muestra cómo las interfaces genéricas pueden utilizarse para implementar patrones de diseño de manera flexible y con seguridad de tipos.
Herencia genérica
La herencia genérica en TypeScript permite combinar los beneficios de la programación orientada a objetos con la flexibilidad de los tipos genéricos. Esta característica nos permite crear jerarquías de clases que preservan la información de tipos a través de la cadena de herencia, manteniendo la seguridad de tipos en todo momento.
Cuando trabajamos con herencia genérica, podemos crear clases base que utilicen parámetros de tipo y luego extenderlas con clases derivadas que pueden mantener, especializar o añadir nuevos parámetros de tipo.
Herencia básica con genéricos
La forma más simple de herencia genérica es cuando una clase derivada hereda los parámetros de tipo de su clase base:
class EntidadBase<T> {
protected id: T;
constructor(id: T) {
this.id = id;
}
obtenerId(): T {
return this.id;
}
}
class Usuario extends EntidadBase<number> {
private nombre: string;
constructor(id: number, nombre: string) {
super(id);
this.nombre = nombre;
}
obtenerNombre(): string {
return this.nombre;
}
}
const usuario = new Usuario(1, "Ana Martínez");
console.log(usuario.obtenerId()); // 1 (tipo number)
console.log(usuario.obtenerNombre()); // "Ana Martínez"
En este ejemplo, Usuario
extiende EntidadBase
especificando que el tipo genérico T
será number
. La clase derivada hereda todos los métodos y propiedades de la clase base, manteniendo la información de tipos.
Propagación de parámetros genéricos
Una clase derivada puede propagar los parámetros genéricos de su clase base, permitiendo que el tipo se especifique al instanciar la clase derivada:
class Repositorio<T> {
protected elementos: T[] = [];
agregar(elemento: T): void {
this.elementos.push(elemento);
}
obtenerTodos(): T[] {
return [...this.elementos];
}
}
class RepositorioConBusqueda<T> extends Repositorio<T> {
buscar(predicado: (elemento: T) => boolean): T[] {
return this.elementos.filter(predicado);
}
}
interface Producto {
id: number;
nombre: string;
precio: number;
}
// El tipo genérico se especifica al instanciar la clase derivada
const repoProductos = new RepositorioConBusqueda<Producto>();
repoProductos.agregar({ id: 1, nombre: "Laptop", precio: 999 });
repoProductos.agregar({ id: 2, nombre: "Teléfono", precio: 699 });
// Usando el método heredado
const todosProductos = repoProductos.obtenerTodos();
// Usando el método de la clase derivada
const productosCaros = repoProductos.buscar(p => p.precio > 800);
console.log(productosCaros); // [{ id: 1, nombre: "Laptop", precio: 999 }]
En este caso, RepositorioConBusqueda<T>
extiende Repositorio<T>
manteniendo el parámetro genérico. Esto permite que el tipo se especifique al crear una instancia de la clase derivada.
Especialización de tipos genéricos
Una clase derivada puede especializar el tipo genérico de su clase base, estableciendo restricciones adicionales:
interface Identificable {
id: number | string;
}
class RepositorioBase<T> {
protected items: T[] = [];
agregar(item: T): void {
this.items.push(item);
}
obtenerTodos(): T[] {
return [...this.items];
}
}
class RepositorioIdentificable<T extends Identificable> extends RepositorioBase<T> {
obtenerPorId(id: number | string): T | undefined {
return this.items.find(item => item.id === id);
}
actualizar(item: T): boolean {
const indice = this.items.findIndex(i => i.id === item.id);
if (indice !== -1) {
this.items[indice] = { ...item };
return true;
}
return false;
}
}
interface Cliente extends Identificable {
id: number;
nombre: string;
email: string;
}
const repoClientes = new RepositorioIdentificable<Cliente>();
repoClientes.agregar({ id: 1, nombre: "Juan Pérez", email: "juan@ejemplo.com" });
// Método específico de la clase derivada que requiere Identificable
const cliente = repoClientes.obtenerPorId(1);
console.log(cliente?.nombre); // "Juan Pérez"
En este ejemplo, RepositorioIdentificable
extiende RepositorioBase
pero añade una restricción adicional: el tipo T
debe implementar la interfaz Identificable
. Esto permite que la clase derivada implemente métodos que dependen de la existencia de una propiedad id
.
Añadir nuevos parámetros genéricos
Una clase derivada puede añadir nuevos parámetros genéricos además de los heredados de la clase base:
class Contenedor<T> {
protected valor: T;
constructor(valor: T) {
this.valor = valor;
}
obtenerValor(): T {
return this.valor;
}
}
class ContenedorConTransformacion<T, U> extends Contenedor<T> {
private transformador: (valor: T) => U;
constructor(valor: T, transformador: (valor: T) => U) {
super(valor);
this.transformador = transformador;
}
obtenerTransformado(): U {
return this.transformador(this.valor);
}
}
// Uso con diferentes tipos
const contenedorNumero = new ContenedorConTransformacion<number, string>(
42,
(num) => `El número es: ${num}`
);
console.log(contenedorNumero.obtenerValor()); // 42
console.log(contenedorNumero.obtenerTransformado()); // "El número es: 42"
// Otro ejemplo con tipos más complejos
interface Persona {
nombre: string;
edad: number;
}
interface ResumenPersona {
nombreCompleto: string;
esAdulto: boolean;
}
const transformarPersona = (p: Persona): ResumenPersona => ({
nombreCompleto: p.nombre,
esAdulto: p.edad >= 18
});
const contenedorPersona = new ContenedorConTransformacion<Persona, ResumenPersona>(
{ nombre: "Carlos Ruiz", edad: 25 },
transformarPersona
);
console.log(contenedorPersona.obtenerTransformado());
// { nombreCompleto: "Carlos Ruiz", esAdulto: true }
En este ejemplo, ContenedorConTransformacion<T, U>
extiende Contenedor<T>
y añade un nuevo parámetro de tipo U
para representar el tipo del valor transformado.
Herencia con interfaces genéricas
La herencia genérica también se aplica cuando las clases implementan interfaces genéricas:
interface Coleccionable<T> {
agregar(item: T): void;
contiene(item: T): boolean;
}
interface ColeccionOrdenable<T> extends Coleccionable<T> {
ordenar(comparador: (a: T, b: T) => number): void;
}
class ListaOrdenada<T> implements ColeccionOrdenable<T> {
private items: T[] = [];
agregar(item: T): void {
this.items.push(item);
}
contiene(item: T): boolean {
return this.items.some(i => i === item);
}
ordenar(comparador: (a: T, b: T) => number): void {
this.items.sort(comparador);
}
obtenerItems(): T[] {
return [...this.items];
}
}
// Uso con números
const listaNumeros = new ListaOrdenada<number>();
listaNumeros.agregar(3);
listaNumeros.agregar(1);
listaNumeros.agregar(2);
listaNumeros.ordenar((a, b) => a - b);
console.log(listaNumeros.obtenerItems()); // [1, 2, 3]
// Uso con objetos
interface Tarea {
id: number;
titulo: string;
prioridad: number;
}
const listaTareas = new ListaOrdenada<Tarea>();
listaTareas.agregar({ id: 1, titulo: "Completar informe", prioridad: 2 });
listaTareas.agregar({ id: 2, titulo: "Revisar código", prioridad: 1 });
listaTareas.agregar({ id: 3, titulo: "Reunión con cliente", prioridad: 3 });
listaTareas.ordenar((a, b) => a.prioridad - b.prioridad);
console.log(listaTareas.obtenerItems());
// Tareas ordenadas por prioridad ascendente
En este ejemplo, ColeccionOrdenable<T>
extiende Coleccionable<T>
, y ListaOrdenada<T>
implementa ColeccionOrdenable<T>
. La cadena de herencia mantiene la información de tipos en cada nivel.
Herencia mixta con clases abstractas genéricas
Las clases abstractas genéricas proporcionan una forma poderosa de definir comportamientos comunes mientras permiten que las clases derivadas especifiquen los tipos concretos:
abstract class ServicioApi<T, ID> {
abstract obtenerUrl(): string;
async obtenerTodos(): Promise<T[]> {
const respuesta = await fetch(this.obtenerUrl());
return respuesta.json();
}
async obtenerPorId(id: ID): Promise<T> {
const respuesta = await fetch(`${this.obtenerUrl()}/${id}`);
return respuesta.json();
}
async crear(item: Omit<T, 'id'>): Promise<T> {
const respuesta = await fetch(this.obtenerUrl(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item)
});
return respuesta.json();
}
}
interface Usuario {
id: number;
nombre: string;
email: string;
}
class ServicioUsuarios extends ServicioApi<Usuario, number> {
obtenerUrl(): string {
return 'https://api.ejemplo.com/usuarios';
}
// Métodos específicos para usuarios
async buscarPorEmail(email: string): Promise<Usuario | undefined> {
const usuarios = await this.obtenerTodos();
return usuarios.find(u => u.email === email);
}
}
// Uso
const servicioUsuarios = new ServicioUsuarios();
// Todos los métodos heredados mantienen los tipos correctos
servicioUsuarios.obtenerPorId(1).then(usuario => {
console.log(usuario.nombre); // Acceso seguro a la propiedad nombre
});
En este ejemplo, ServicioApi<T, ID>
es una clase abstracta genérica que define operaciones CRUD comunes. La clase ServicioUsuarios
extiende esta clase base especificando los tipos concretos Usuario
y number
(para el ID).
Patrones avanzados de herencia genérica
La herencia genérica permite implementar patrones de diseño avanzados como el patrón Método de Plantilla (Template Method):
abstract class ProcesadorDatos<T, R> {
// Método plantilla que define el algoritmo
procesar(datos: T[]): R[] {
const datosFiltrados = this.filtrar(datos);
const datosTransformados = datosFiltrados.map(dato => this.transformar(dato));
return this.ordenar(datosTransformados);
}
// Métodos abstractos que deben implementar las subclases
protected abstract filtrar(datos: T[]): T[];
protected abstract transformar(dato: T): R;
protected abstract ordenar(datos: R[]): R[];
}
interface DatoNumerico {
valor: number;
etiqueta: string;
activo: boolean;
}
interface ResultadoEstadistico {
etiqueta: string;
valorNormalizado: number;
}
class ProcesadorEstadistico extends ProcesadorDatos<DatoNumerico, ResultadoEstadistico> {
protected filtrar(datos: DatoNumerico[]): DatoNumerico[] {
return datos.filter(dato => dato.activo);
}
protected transformar(dato: DatoNumerico): ResultadoEstadistico {
return {
etiqueta: dato.etiqueta,
valorNormalizado: dato.valor / 100
};
}
protected ordenar(datos: ResultadoEstadistico[]): ResultadoEstadistico[] {
return [...datos].sort((a, b) => b.valorNormalizado - a.valorNormalizado);
}
}
// Uso del procesador
const datosEntrada: DatoNumerico[] = [
{ valor: 120, etiqueta: "A", activo: true },
{ valor: 80, etiqueta: "B", activo: false },
{ valor: 160, etiqueta: "C", activo: true }
];
const procesador = new ProcesadorEstadistico();
const resultados = procesador.procesar(datosEntrada);
console.log(resultados);
// [
// { etiqueta: "C", valorNormalizado: 1.6 },
// { etiqueta: "A", valorNormalizado: 1.2 }
// ]
Este patrón permite definir el esqueleto de un algoritmo en la clase base mientras delega los pasos específicos a las subclases, todo ello manteniendo la seguridad de tipos.
Consideraciones y mejores prácticas
Al trabajar con herencia genérica en TypeScript, es importante tener en cuenta algunas consideraciones:
Evita jerarquías profundas: Las cadenas de herencia demasiado profundas pueden hacer que el código sea difícil de entender y mantener.
Usa restricciones adecuadas: Utiliza
extends
para restringir los tipos genéricos cuando sea necesario, lo que proporciona mejor autocompletado y detección de errores.Considera la composición: En algunos casos, la composición puede ser una alternativa más flexible que la herencia.
Documenta los parámetros de tipo: Especialmente en jerarquías complejas, es útil documentar el propósito de cada parámetro de tipo.
/**
* Representa un servicio que gestiona entidades con identificadores.
* @template T - El tipo de entidad gestionada
* @template ID - El tipo de identificador de la entidad
*/
abstract class Servicio<T, ID> {
// Implementación...
}
La herencia genérica es una herramienta poderosa que, cuando se utiliza correctamente, permite crear abstracciones flexibles y reutilizables que mantienen la seguridad de tipos en toda la jerarquía de clases.
Instanciación de genéricos
La instanciación de genéricos es el proceso mediante el cual se proporciona un tipo concreto a una estructura genérica en TypeScript. Este paso es fundamental para transformar una plantilla genérica en un componente específico y utilizable con un tipo determinado.
Cuando instanciamos un genérico, estamos "rellenando" los parámetros de tipo con tipos concretos, lo que permite al compilador realizar comprobaciones de tipo adecuadas y proporcionar autocompletado específico para ese tipo.
Sintaxis de instanciación
La instanciación de genéricos se realiza proporcionando los tipos concretos entre corchetes angulares <>
. Existen varias formas de instanciar genéricos dependiendo del contexto:
// Instanciación explícita de una clase genérica
const listaNombres = new Lista<string>();
// Instanciación explícita al llamar a una función genérica
const resultado = convertir<string, number>("42", parseInt);
// Instanciación explícita al crear un objeto que implementa una interfaz genérica
const respuesta: Respuesta<Usuario> = {
datos: { id: 1, nombre: "Elena" },
exito: true
};
Inferencia de tipos en genéricos
TypeScript puede inferir automáticamente los tipos genéricos en muchos casos, lo que hace que la instanciación explícita sea opcional:
// Inferencia automática basada en los argumentos
function identidad<T>(valor: T): T {
return valor;
}
// TypeScript infiere que T es number
const num = identidad(42);
// TypeScript infiere que T es string
const texto = identidad("hola");
// TypeScript infiere tipos complejos también
const usuario = identidad({ id: 1, nombre: "Carlos" });
Sin embargo, hay situaciones donde la inferencia no es posible y se requiere una instanciación explícita:
// Sin argumentos, TypeScript no puede inferir el tipo
function crearArray<T>(): T[] {
return [];
}
// Instanciación explícita necesaria
const numeros = crearArray<number>();
const textos = crearArray<string>();
Instanciación con valores predeterminados
TypeScript permite definir valores predeterminados para parámetros de tipo, lo que puede simplificar la instanciación:
// Clase con valor predeterminado para el parámetro de tipo
class Almacen<T = string> {
private elementos: T[] = [];
agregar(elemento: T): void {
this.elementos.push(elemento);
}
obtenerTodos(): T[] {
return [...this.elementos];
}
}
// Instanciación sin especificar tipo (usa el predeterminado: string)
const almacenTextos = new Almacen();
almacenTextos.agregar("uno");
almacenTextos.agregar("dos");
// Instanciación explícita con otro tipo
const almacenNumeros = new Almacen<number>();
almacenNumeros.agregar(1);
almacenNumeros.agregar(2);
Instanciación con tipos condicionales
La instanciación puede volverse más sofisticada con tipos condicionales, permitiendo que el tipo resultante dependa de condiciones:
// Tipo condicional
type ElementoArray<T> = T extends Array<infer E> ? E : never;
// Instanciación con diferentes tipos
type TipoItem1 = ElementoArray<string[]>; // string
type TipoItem2 = ElementoArray<number[]>; // number
type TipoItem3 = ElementoArray<boolean>; // never (no es un array)
// Uso práctico en una función
function primerElemento<T>(array: T[]): ElementoArray<T[]> {
return array[0];
}
const primero = primerElemento([1, 2, 3]); // tipo: number
const primeroTexto = primerElemento(["a", "b", "c"]); // tipo: string
Instanciación parcial de genéricos
En algunos casos, podemos realizar una instanciación parcial de genéricos, especificando solo algunos de los parámetros de tipo:
// Clase con múltiples parámetros de tipo
class Diccionario<K, V> {
private items: Map<K, V> = new Map();
establecer(clave: K, valor: V): void {
this.items.set(clave, valor);
}
obtener(clave: K): V | undefined {
return this.items.get(clave);
}
}
// Creación de un tipo parcialmente instanciado
type DiccionarioDeStrings<K> = Diccionario<K, string>;
// Uso del tipo parcialmente instanciado
const diccionario = new DiccionarioDeStrings<number>();
diccionario.establecer(1, "uno");
diccionario.establecer(2, "dos");
const valor = diccionario.obtener(1); // tipo: string | undefined
Instanciación con restricciones
Cuando trabajamos con genéricos que tienen restricciones, debemos asegurarnos de que los tipos proporcionados cumplan con esas restricciones:
// Interfaz para la restricción
interface ConNombre {
nombre: string;
}
// Función con restricción genérica
function imprimirNombre<T extends ConNombre>(entidad: T): void {
console.log(entidad.nombre);
}
// Instanciación correcta (cumple con la restricción)
imprimirNombre({ nombre: "Ana", edad: 30 });
// Error: el tipo no cumple con la restricción
// imprimirNombre({ edad: 25 });
// Clase con restricción genérica
class Registro<T extends ConNombre> {
private entidades: T[] = [];
agregar(entidad: T): void {
this.entidades.push(entidad);
}
buscarPorNombre(nombre: string): T | undefined {
return this.entidades.find(e => e.nombre === nombre);
}
}
// Instanciación con un tipo que cumple la restricción
interface Empleado extends ConNombre {
id: number;
departamento: string;
}
const registroEmpleados = new Registro<Empleado>();
registroEmpleados.agregar({ id: 1, nombre: "Juan", departamento: "IT" });
Instanciación con tipos mapeados
Los tipos mapeados ofrecen una forma poderosa de transformar tipos existentes, y pueden combinarse con la instanciación de genéricos:
// Tipo mapeado para hacer todas las propiedades opcionales
type Parcial<T> = {
[P in keyof T]?: T[P];
};
interface Producto {
id: number;
nombre: string;
precio: number;
stock: number;
}
// Instanciación del tipo mapeado
type ProductoParcial = Parcial<Producto>;
// Uso del tipo instanciado
const actualizacionProducto: ProductoParcial = {
precio: 29.99,
stock: 100
};
// Función que utiliza el tipo mapeado
function actualizarProducto(id: number, datos: Parcial<Producto>): void {
// Implementación...
}
actualizarProducto(1, { precio: 19.99 });
Instanciación con genéricos anidados
La instanciación puede volverse más compleja con genéricos anidados, donde un tipo genérico contiene otro:
// Tipos genéricos anidados
interface Resultado<T> {
datos: T;
errores: string[];
}
interface ListaPaginada<T> {
items: T[];
total: number;
pagina: number;
}
// Instanciación con genéricos anidados
type ResultadoUsuarios = Resultado<ListaPaginada<Usuario>>;
// Uso del tipo instanciado
const respuestaAPI: ResultadoUsuarios = {
datos: {
items: [
{ id: 1, nombre: "Ana" },
{ id: 2, nombre: "Luis" }
],
total: 50,
pagina: 1
},
errores: []
};
// Función que trabaja con tipos anidados
function procesarResultado<T>(resultado: Resultado<ListaPaginada<T>>): T[] {
if (resultado.errores.length > 0) {
throw new Error("Error en la respuesta");
}
return resultado.datos.items;
}
const usuarios = procesarResultado(respuestaAPI);
Instanciación dinámica con genéricos
En algunos casos, podemos necesitar una instanciación dinámica basada en condiciones en tiempo de ejecución:
// Función factory que crea instancias de diferentes tipos
function crearServicio<T>(tipo: string): T {
if (tipo === "usuarios") {
return new ServicioUsuarios() as unknown as T;
} else if (tipo === "productos") {
return new ServicioProductos() as unknown as T;
} else {
throw new Error(`Tipo de servicio desconocido: ${tipo}`);
}
}
// Interfaces para los diferentes servicios
interface ServicioUsuarios {
obtenerUsuarios(): Promise<Usuario[]>;
obtenerPorId(id: number): Promise<Usuario>;
}
interface ServicioProductos {
listarProductos(): Promise<Producto[]>;
buscarPorNombre(nombre: string): Promise<Producto[]>;
}
// Instanciación dinámica basada en una condición
const tipoServicio = obtenerConfiguracion().tipoServicio;
const servicio = crearServicio<ServicioUsuarios | ServicioProductos>(tipoServicio);
// Uso con comprobación de tipo
if ("obtenerUsuarios" in servicio) {
// Es un ServicioUsuarios
servicio.obtenerUsuarios().then(usuarios => {
console.log(usuarios);
});
} else {
// Es un ServicioProductos
servicio.listarProductos().then(productos => {
console.log(productos);
});
}
Consideraciones prácticas
Al instanciar genéricos en TypeScript, es importante tener en cuenta algunas consideraciones prácticas:
- Legibilidad vs. inferencia: Aunque TypeScript puede inferir muchos tipos, a veces es mejor ser explícito para mejorar la legibilidad del código.
// Menos legible (depende de la inferencia)
const resultado = obtenerDatos(api).then(procesar);
// Más legible (tipo explícito)
const resultado = obtenerDatos<Usuario>(api).then(procesar);
- Evitar la sobre-especificación: No es necesario especificar tipos que TypeScript puede inferir fácilmente.
// Innecesariamente verboso
const lista = new Array<number>();
// Preferible (inferencia automática)
const lista = [1, 2, 3];
- Uso de alias de tipo: Para genéricos complejos, considera crear alias de tipo para mejorar la legibilidad.
// Difícil de leer
function procesar(datos: Resultado<ListaPaginada<Entidad<Usuario>>>): void {
// Implementación...
}
// Más legible con alias de tipo
type ResultadoPaginadoUsuarios = Resultado<ListaPaginada<Entidad<Usuario>>>;
function procesar(datos: ResultadoPaginadoUsuarios): void {
// Implementación...
}
- Compatibilidad con versiones anteriores: Al actualizar código existente para usar genéricos, asegúrate de mantener la compatibilidad con el código que lo consume.
La instanciación de genéricos es una técnica poderosa que permite crear código flexible y reutilizable sin sacrificar la seguridad de tipos. Dominar esta característica es esencial para aprovechar al máximo el sistema de tipos de TypeScript.
Otros ejercicios de programación de TypeScript
Evalúa tus conocimientos de esta lección Genéricos con clases e interfaces 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 la definición y uso de clases genéricas para manejar diferentes tipos de datos.
- Aprender a aplicar interfaces genéricas para definir contratos flexibles y seguros.
- Entender cómo funciona la herencia genérica y cómo extender clases e interfaces genéricas.
- Conocer la instanciación de genéricos, incluyendo inferencia, restricciones y valores predeterminados.
- Aplicar genéricos en patrones de diseño y estructuras de datos comunes manteniendo la seguridad de tipos.