Tipado de Genéricos en Funciones y Clases con TypeScript: Flexibilidad y Seguridad
Este tutorial te guiará a través del uso de genéricos en TypeScript, una característica poderosa que permite escribir componentes flexibles y reutilizables sin sacrificar la seguridad de tipos. Exploraremos cómo aplicar genéricos a funciones, interfaces y clases, y cómo restringirlos para un control más preciso.
TypeScript es conocido por su sistema de tipos robusto, pero a veces necesitas escribir código que pueda funcionar con una variedad de tipos de datos. Aquí es donde entran en juego los genéricos. Los genéricos te permiten escribir componentes que funcionan con cualquier tipo de datos, proporcionando flexibilidad y reutilización, mientras mantienen los beneficios de la comprobación de tipos.
En este tutorial, profundizaremos en cómo utilizar los genéricos en TypeScript para crear código más adaptable y seguro, especialmente en el contexto de funciones y clases.
💡 ¿Qué son los Genéricos en TypeScript?
Imagina que quieres escribir una función que devuelve el primer elemento de un array. Sin genéricos, podrías tener que sobrecargar la función para cada tipo de array posible (string, number, boolean, etc.) o usar el tipo any, perdiendo la información de tipo.
Los genéricos resuelven esto al permitirte definir un parámetro de tipo que se utiliza en la definición de una función, interfaz o clase. Este parámetro de tipo es un marcador de posición para el tipo real que se utilizará cuando se invoque la función o se instancie la clase.
Sintaxis Básica de Genéricos
La sintaxis para los genéricos utiliza <T> (o cualquier otra letra, aunque T es la convención más común para "Type").
function identity<T>(arg: T): T {
return arg;
}
// Usando la función identity
let output1 = identity<string>("Hola"); // output1 es de tipo string
let output2 = identity<number>(123); // output2 es de tipo number
// TypeScript también puede inferir el tipo:
let output3 = identity("Mundo"); // output3 es de tipo string
let output4 = identity(456); // output4 es de tipo number
En el ejemplo anterior, T es el parámetro de tipo. Cuando llamamos a identity<string>("Hola"), T se convierte en string para esa invocación, y TypeScript garantiza que tanto el argumento arg como el valor de retorno sean de tipo string.
🎯 Genéricos en Funciones
Los genéricos en funciones son quizás el caso de uso más común y directo. Permiten que una función opere con una amplia gama de tipos, manteniendo la seguridad de tipos.
Función de Identidad (Revisión)
Ya vimos la función identity. Su propósito es simplemente devolver el argumento que se le pasa.
function identity<T>(arg: T): T {
console.log(typeof arg); // Podemos ver el tipo en tiempo de ejecución
return arg;
}
let cadena = identity("TypeScript es genial"); // cadena es string
let numero = identity(100); // numero es number
let booleano = identity(true); // booleano es boolean
console.log(cadena); // "TypeScript es genial"
console.log(numero); // 100
console.log(booleano); // true
Observa cómo no necesitamos especificar el tipo T explícitamente cuando llamamos a la función; TypeScript es lo suficientemente inteligente como para inferirlo a partir del argumento que le pasamos. Esto es muy conveniente.
Trabajando con Múltiples Parámetros de Tipo
Puedes tener múltiples parámetros de tipo en una función si es necesario.
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
let miPar = pair("Hola", 123); // miPar es de tipo [string, number]
console.log(miPar); // ["Hola", 123]
let otroPar = pair(true, { nombre: "Juan" }); // otroPar es de tipo [boolean, { nombre: string }]
console.log(otroPar); // [true, { nombre: "Juan" }]
Aquí, T y U son dos parámetros de tipo diferentes, lo que permite que la función pair cree tuplas con tipos variados.
Restricciones de Tipo (Type Constraints)
¿Qué pasa si quieres que tu función genérica opere solo en tipos que tienen ciertas propiedades? Aquí es donde las restricciones de tipo son útiles. Puedes usar la palabra clave extends para limitar los tipos que T puede ser.
Por ejemplo, si queremos una función que imprima la longitud de un argumento, necesitamos asegurarnos de que el argumento tenga una propiedad length.
interface Lengthwise {
length: number;
}
function loggingIdentity<T extends Lengthwise>(arg: T): T {
console.log(arg.length); // Ahora sabemos que 'arg' tiene una propiedad .length
return arg;
}
// Esto funciona:
loggingIdentity({ length: 10, value: 3 });
loggingIdentity("Hola Mundo");
loggingIdentity([1, 2, 3]);
// Esto NO funcionará (error de compilación porque number no tiene .length):
// loggingIdentity(3);
En este caso, T debe extender la interfaz Lengthwise. Esto significa que cualquier tipo que se pase a loggingIdentity debe tener al menos una propiedad length de tipo number. Esto proporciona un potente mecanismo para hacer genéricos más seguros y específicos.
Uso de Parámetros de Tipo en Restricciones
También puedes restringir un parámetro de tipo en función de otro parámetro de tipo.
function getProperty<T, K extends keyof T>(obj: T, key: K) {
return obj[key];
}
let x = { a: 1, b: 2, c: 3, d: 4 };
console.log(getProperty(x, "a")); // 1
console.log(getProperty(x, "b")); // 2
// console.log(getProperty(x, "m")); // Error de compilación: 'm' no existe en 'x'
Aquí, K extends keyof T asegura que el segundo argumento key debe ser una clave válida del primer argumento obj. Esto es increíblemente útil para trabajar con objetos de forma segura.
🏗️ Genéricos en Clases
Así como las funciones, las clases también pueden ser genéricas. Esto permite crear clases que operan sobre un tipo de datos específico sin tener que duplicar el código para cada tipo.
Clase Genérica Básica
Una clase genérica se declara con uno o más parámetros de tipo después del nombre de la clase.
class Box<T> {
private value: T;
constructor(initialValue: T) {
this.value = initialValue;
}
getValue(): T {
return this.value;
}
setValue(newValue: T) {
this.value = newValue;
}
}
let numberBox = new Box<number>(10);
console.log(numberBox.getValue()); // 10
numberBox.setValue(20);
console.log(numberBox.getValue()); // 20
// numberBox.setValue("Hola"); // Error de compilación: Argumento de tipo 'string' no asignable a parámetro de tipo 'number'.
let stringBox = new Box<string>("TypeScript");
console.log(stringBox.getValue()); // TypeScript
stringBox.setValue("Genéricos");
console.log(stringBox.getValue()); // Genéricos
La clase Box<T> puede almacenar cualquier tipo T. Una vez que se especifica el tipo T durante la instanciación (new Box<number>(...)), todas las operaciones dentro de esa instancia de Box se aplicarán a ese tipo específico.
Clases Genéricas con Restricciones
Al igual que en las funciones, puedes aplicar restricciones a los parámetros de tipo de las clases genéricas.
interface HasId {
id: string | number;
}
class Repository<T extends HasId> {
private items: Map<string | number, T> = new Map();
add(item: T) {
this.items.set(item.id, item);
}
get(id: string | number): T | undefined {
return this.items.get(id);
}
getAll(): T[] {
return Array.from(this.items.values());
}
}
interface User extends HasId {
id: string;
name: string;
email: string;
}
interface Product extends HasId {
id: number;
name: string;
price: number;
}
const userRepository = new Repository<User>();
userRepository.add({ id: "1", name: "Alice", email: "alice@example.com" });
userRepository.add({ id: "2", name: "Bob", email: "bob@example.com" });
console.log(userRepository.get("1"));
console.log(userRepository.getAll());
const productRepository = new Repository<Product>();
productRepository.add({ id: 101, name: "Laptop", price: 1200 });
productRepository.add({ id: 102, name: "Mouse", price: 25 });
console.log(productRepository.get(102));
// userRepository.add({ name: "Charlie" }); // Error: Property 'id' is missing
Aquí, Repository<T extends HasId> asegura que cualquier tipo T utilizado con esta clase debe tener una propiedad id. Esto nos permite construir un repositorio genérico que puede almacenar diferentes tipos de entidades, siempre y cuando cumplan con el contrato HasId.
Diagrama de Flujo: Cómo Funcionan los Genéricos
✨ Genéricos en Interfaces
Las interfaces también pueden ser genéricas, lo que las hace muy flexibles para definir estructuras de datos que manejan diferentes tipos.
Interfaz Genérica Simple
interface GenericContainer<T> {
value: T;
description: string;
}
let stringContainer: GenericContainer<string> = {
value: "Hello World",
description: "Un mensaje de texto"
};
let numberContainer: GenericContainer<number> = {
value: 42,
description: "Un número importante"
};
console.log(stringContainer.value.toUpperCase()); // HELLO WORLD
console.log(numberContainer.value.toFixed(2)); // 42.00
// let booleanContainer: GenericContainer<boolean> = {
// value: "true", // Error de tipo: 'string' no es asignable a 'boolean'
// description: "Estado"
// };
La interfaz GenericContainer<T> define un contrato para cualquier objeto que contenga un value de tipo T y una description de tipo string.
Interfaz para Funciones Genéricas
También puedes definir interfaces que describan tipos de funciones genéricas.
interface GenericFunction<T> {
(arg: T): T;
}
function identityFn<T>(arg: T): T {
return arg;
}
let myIdentity: GenericFunction<number> = identityFn;
console.log(myIdentity(50)); // 50
// console.log(myIdentity("test")); // Error: 'string' no es asignable a 'number'
En este caso, GenericFunction<T> describe una función que toma un argumento de tipo T y devuelve un valor de tipo T. Cuando asignamos identityFn a myIdentity, especificamos que T es number para myIdentity.
🛠️ Ejemplos Avanzados y Patrones Comunes
Genéricos con Clases Abstractas
Los genéricos son muy útiles cuando se trabaja con clases abstractas para crear una base tipada para subclases.
abstract class DataProcessor<T> {
protected data: T[];
constructor(initialData: T[]) {
this.data = initialData;
}
abstract process(): T[];
// Método genérico no abstracto
addData(item: T) {
this.data.push(item);
}
getData(): T[] {
return this.data;
}
}
class NumberSorter extends DataProcessor<number> {
constructor(numbers: number[]) {
super(numbers);
}
process(): number[] {
return this.data.sort((a, b) => a - b);
}
}
class StringReverser extends DataProcessor<string> {
constructor(strings: string[]) {
super(strings);
}
process(): string[] {
return this.data.map(s => s.split('').reverse().join(''));
}
}
const sorter = new NumberSorter([5, 2, 8, 1]);
sorter.addData(3);
console.log("Sorted numbers:", sorter.process()); // [1, 2, 3, 5, 8]
const reverser = new StringReverser(["hola", "mundo"]);
reverser.addData("ts");
console.log("Reversed strings:", reverser.process()); // ["aloh", "odnum", "st"]
Aquí, DataProcessor<T> proporciona una estructura genérica para procesar cualquier tipo de datos, dejando la lógica de procesamiento específica a las subclases.
Uso de Genéricos con Decoradores
Aunque el tema de los decoradores ya se cubrió en otro tutorial, es importante mencionar que los genéricos son fundamentales al escribir decoradores para proporcionar tipado seguro a los parámetros y al valor retornado por el decorador.
// Ejemplo simplificado de un decorador de método genérico
function logMethod<T>(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) {
let originalMethod = descriptor.value as T;
descriptor.value = function(...args: any[]) {
console.log(`Llamando al método ${String(propertyKey)} con argumentos:`, args);
const result = (originalMethod as any).apply(this, args);
console.log(`Método ${String(propertyKey)} devolvió:`, result);
return result;
} as T;
return descriptor;
}
class Calculator {
@logMethod
add(a: number, b: number): number {
return a + b;
}
@logMethod
subtract(a: number, b: number): number {
return a - b;
}
}
const calc = new Calculator();
calc.add(5, 3);
calc.subtract(10, 4);
El TypedPropertyDescriptor<T> permite que el decorador sepa el tipo exacto del método al que se está aplicando, permitiendo un tipado seguro en el originalMethod y el valor de retorno.
FAQ sobre Genéricos
¿Cuándo debo usar genéricos en lugar de `any`?
Siempre que sea posible, prefiere los genéricos sobre any. Los genéricos te proporcionan seguridad de tipos en tiempo de compilación, mientras que any desactiva todas las comprobaciones de tipos, lo que puede llevar a errores en tiempo de ejecución difíciles de depurar.
¿Puedo tener valores por defecto para los parámetros de tipo genéricos?
Sí, TypeScript 4.0 introdujo la capacidad de especificar parámetros de tipo por defecto. Por ejemplo: interface Box<T = string> { value: T; }. Si no se proporciona un tipo, T tomará el valor string.
¿Cuál es la diferencia entre un genérico y una sobrecarga de función?
Los genéricos son para funciones que operan de la misma manera en *diferentes tipos*. Las sobrecargas son para funciones que tienen *diferentes implementaciones* o firmas dependiendo de los tipos de argumentos. Los genéricos suelen resultar en menos código y más reutilización.
🚀 Buenas Prácticas y Consideraciones
Aquí tienes algunos puntos clave para recordar al trabajar con genéricos:
- Nombra los parámetros de tipo de forma descriptiva: Aunque
Tes común, para genéricos más complejos, usa nombres más descriptivos comoTKey,TValue,TElement, etc. - Usa restricciones cuando sea necesario: No restrinjas excesivamente, pero si tu lógica interna depende de ciertas propiedades o métodos, usa
extendspara garantizar la seguridad. - Inferencia vs. Explicititud: Deja que TypeScript infiera los tipos genéricos cuando sea obvio para mantener el código conciso, pero sé explícito cuando la inferencia sea ambigua o cuando quieras documentar intenciones.
- Genéricos y Union Types: A veces, un union type (
string | number) puede ser suficiente si solo necesitas manejar un conjunto fijo y pequeño de tipos. Los genéricos brillan cuando el código necesita ser verdaderamente agnóstico al tipo o cuando los tipos provienen de un contexto externo (como una API).
Tabla Comparativa: Genéricos vs. any vs. Union Types
| Característica | Genéricos (<T>) | any | Union Types (A | B) |
| :------------------------ | :------------------------------------------------- | :--------------------------------------- | :----------------------------------------------- |
|---|---|---|---|
| Seguridad de Tipos | ✅ Alta (en tiempo de compilación) | ❌ Nula (desactiva comprobaciones) | ✅ Alta (en tiempo de compilación) |
| Flexibilidad | ✨ Muy alta (para cualquier tipo posible) | 🔥 Muy alta (aceptar cualquier cosa) | Media (para un conjunto fijo de tipos) |
| --- | --- | --- | --- |
| Reutilización | ✅ Excelente (componentes polimórficos) | Buena (pero sin seguridad) | Limitada a los tipos de la unión |
| Inferencia de Tipos | ✅ Sí | No (todo es any) | ✅ Sí |
| --- | --- | --- | --- |
| Restricciones | ✅ Sí (extends) | No aplicable | Implícitas por los tipos en la unión |
| Mantenibilidad | Alta (código robusto y claro) | Baja (difícil de refactorizar) | Media-Alta (claro para tipos conocidos) |
Conclusión
Los genéricos son una de las características más poderosas y fundamentales de TypeScript. Te permiten escribir código altamente reutilizable y flexible sin renunciar a la seguridad de tipos que TypeScript ofrece. Al dominar los genéricos en funciones, clases e interfaces, podrás construir aplicaciones más robustas, escalables y fáciles de mantener.
Esperamos que este tutorial te haya proporcionado una comprensión sólida de cómo y cuándo usar genéricos. ¡Ahora sal y escribe un código TypeScript más potente y genérico!
Tutoriales relacionados
- Tipado Avanzado de Redux y Redux Toolkit con TypeScript: Una Guía Completaintermediate15 min
- Tipado de Eventos en el DOM con TypeScript: Guía Completa para Interfaces y Manejadoresintermediate10 min
- Tipos Utilitarios en TypeScript: Potenciando Tu Código con Mapped Types y Condicionalesadvanced18 min
- Tipado de Configuración y Variables de Entorno en TypeScript: Robustez en tu Aplicaciónintermediate15 min
- Desentrañando los Módulos de Declaración en TypeScript: Globales vs. de Módulointermediate18 min
Comentarios (0)
Aún no hay comentarios. ¡Sé el primero!