Genéricos en TypeScript
En esta lección
- El problema: any o copiar y pegar
- Tu primera función genérica
- Inferencia: casi nunca escribes el tipo
- La función identidad y la diferencia con unknown
- Varios parámetros de tipo
- Restricciones con extends
- keyof en genéricos: leer una propiedad
- Interfaces y tipos genéricos
- Clases genéricas
- Valores por defecto
- Cuándo usar genéricos
- Errores frecuentes
- Resumen
Hasta ahora cada función que has tipado trabajaba con tipos concretos: recibe un number, devuelve un string. Pero muchas funciones hacen lo mismo con cualquier tipo de dato: sacar el primer elemento de un array, guardar algo en una caja, envolver la respuesta de una API. Los genéricos permiten escribir ese código una sola vez y que TypeScript siga sabiendo exactamente qué tipo entra y qué tipo sale. En esta lección verás qué problema resuelven, cómo escribir funciones, interfaces y clases genéricas, y cómo ponerles límites con extends.
El problema: any o copiar y pegar
Quieres una función que devuelva el último elemento de un array. Primera idea, para números:
function ultimoNumero(lista: number[]): number | undefined {
return lista[lista.length - 1];
}
Ahora necesitas lo mismo para textos, y luego para productos… Tendrías que copiar la función una y otra vez cambiando solo el tipo. La segunda idea es usar any:
function ultimo(lista: any[]): any {
return lista[lista.length - 1];
}
const ciudad = ultimo(["Santander", "Bilbao", "Oviedo"]);
console.log(ciudad.toFixed(2));
// Compila sin errores, pero al ejecutar:
// TypeError: ciudad.toFixed is not a function
Con any la función acepta de todo, pero TypeScript pierde la pista del tipo: ciudad es any y puedes llamar a cualquier cosa sobre ella sin aviso. Es como volver a JavaScript.
Lo que queremos es decir: “esta función recibe un array de algún tipo y devuelve un elemento de ese mismo tipo”.
Tu primera función genérica
Eso se escribe con un parámetro de tipo entre < >:
function ultimo<T>(lista: T[]): T | undefined {
return lista[lista.length - 1];
}
const ciudad = ultimo(["Santander", "Bilbao", "Oviedo"]); // string | undefined
const nota = ultimo([7, 9.5, 6]); // number | undefined
console.log(ciudad); // Oviedo
console.log(nota); // 6
Tes una variable de tipo: un hueco que se rellena con un tipo concreto cada vez que llamas a la función.lista: T[]dice “un array de elementos de tipoT” y: T | undefined, “devuelvo uno de esos elementos (oundefinedsi el array está vacío)”.- En la primera llamada
Tvalestring; en la segunda,number. Es la misma función, pero el resultado está bien tipado en cada caso.
El nombre T es una convención (de Type). Puedes llamarlo como quieras, pero verás casi siempre letras mayúsculas: T, U, K (key), V (value) o nombres descriptivos como TItem.
Ya usabas genéricos sin saberlo: Array<string> es lo mismo que string[], y Promise<Response> es una promesa que, cuando se resuelve, da un Response.
Inferencia: casi nunca escribes el tipo
En el ejemplo anterior no has escrito ultimo<string>(...): TypeScript infiere T mirando los argumentos. Puedes indicarlo a mano cuando quieras forzarlo o cuando no hay argumentos de los que deducirlo:
function crearLista<T>(): T[] {
return [];
}
const pendientes = crearLista<string>();
pendientes.push("Comprar cuerda");
pendientes.push(42);
// error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
Aquí no hay ningún argumento, así que sin <string> TypeScript no tendría pistas y T sería unknown. La regla: deja que infiera siempre que pueda y escribe el tipo solo cuando haga falta.
La función identidad y la diferencia con unknown
El ejemplo más sencillo de genérico es la función identidad, que devuelve lo mismo que recibe:
function identidad<T>(valor: T): T {
return valor;
}
const a = identidad("hola"); // tipo "hola"
const b = identidad(3.5); // tipo 3.5
const c = identidad([1, 2]); // tipo number[]
Parece inútil, pero enseña lo importante: el tipo de salida depende del de entrada. Si la escribieras con unknown ((valor: unknown): unknown), sería segura, pero perderías el tipo: tendrías que estrechar el resultado antes de usarlo. El genérico conserva la información.
Varios parámetros de tipo
Una función puede tener tantos parámetros de tipo como necesite, separados por comas:
function emparejar<A, B>(primero: A, segundo: B): [A, B] {
return [primero, segundo];
}
const par = emparejar("Picos de Europa", 2648); // [string, number]
console.log(par); // [ 'Picos de Europa', 2648 ]
function transformar<T, R>(lista: T[], fn: (item: T) => R): R[] {
const resultado: R[] = [];
for (const item of lista) {
resultado.push(fn(item));
}
return resultado;
}
const longitudes = transformar(["pan", "leche", "huevos"], (p) => p.length);
console.log(longitudes); // [ 3, 5, 6 ]
transformar es una versión casera de map. Fíjate en que TypeScript deduce T = string por el array y R = number por lo que devuelve la función flecha. Además, dentro de la flecha p ya es string, sin anotarlo. Así es como están tipados map, filter o reduce en la librería estándar.
Restricciones con extends
A veces un genérico no puede ser cualquier tipo. Esta función debería devolver el elemento más largo, pero no compila:
function masLargo<T>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
// error TS2339: Property 'length' does not exist on type 'T'.
TypeScript tiene razón: si T fuera number, no habría length. La solución es restringir T con extends: “T puede ser cualquier tipo, siempre que tenga una propiedad length numérica”:
function masLargo<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
console.log(masLargo("Laredo", "Castro Urdiales")); // Castro Urdiales
console.log(masLargo([1, 2, 3], [4])); // [ 1, 2, 3 ]
masLargo(10, 20);
// error TS2345: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.
Aquí extends no significa herencia de clases: significa “tiene que cumplir esta forma”. Las restricciones suelen ser interfaces:
interface ConId {
id: number;
}
function buscarPorId<T extends ConId>(lista: T[], id: number): T | undefined {
return lista.find((item) => item.id === id);
}
const socios = [
{ id: 1, nombre: "Irene", cuota: 35 },
{ id: 2, nombre: "Pablo", cuota: 40 },
];
const s = buscarPorId(socios, 2);
console.log(s?.nombre); // Pablo
Lo interesante es que s no es un simple ConId: es { id: number; nombre: string; cuota: number } | undefined, así que puedes usar s?.nombre con autocompletado. La restricción pide lo mínimo y el genérico conserva todo lo demás.
keyof en genéricos: leer una propiedad
Un caso clásico combina dos parámetros de tipo. Queremos una función que lea una propiedad de un objeto y que solo acepte nombres de propiedades que existan:
function obtener<T, K extends keyof T>(objeto: T, clave: K): T[K] {
return objeto[clave];
}
const libro = { titulo: "Niebla", autor: "Unamuno", paginas: 230 };
const t = obtener(libro, "titulo"); // string
const p = obtener(libro, "paginas"); // number
console.log(t, p); // Niebla 230
obtener(libro, "editorial");
// error TS2345: Argument of type '"editorial"' is not assignable to parameter of type '"titulo" | "autor" | "paginas"'.
Vamos por partes:
keyof Tes la unión de las claves deT: aquí"titulo" | "autor" | "paginas".K extends keyof Tobliga a que la clave sea una de ellas.T[K]es un tipo indexado: “el tipo de la propiedadKdeT”. Si pides"paginas", el resultado esnumber.
Este patrón aparece por todas partes: funciones para ordenar por un campo, agrupar, filtrar tablas… En tipos utilitarios verás más sobre keyof y los tipos indexados.
Interfaces y tipos genéricos
Las interfaces y los alias de tipo también pueden tener parámetros. Un uso muy práctico es describir la respuesta de una API, que siempre tiene la misma estructura pero con datos distintos:
interface Respuesta<T> {
ok: boolean;
datos: T;
error?: string;
}
interface Ruta {
nombre: string;
km: number;
}
const respuestaRutas: Respuesta<Ruta[]> = {
ok: true,
datos: [
{ nombre: "Senda del Oso", km: 22 },
{ nombre: "Ruta del Cares", km: 12 },
],
};
const respuestaUsuario: Respuesta<{ nombre: string }> = {
ok: false,
datos: { nombre: "" },
error: "Usuario no encontrado",
};
console.log(respuestaRutas.datos[0].km); // 22
console.log(respuestaUsuario.error); // Usuario no encontrado
Defines la estructura una vez y la reutilizas con Respuesta<Ruta[]>, Respuesta<Usuario>, etc. En la lección del DOM la usarás con fetch.
Con type funciona igual. Este alias describe el resultado de una operación que puede ir bien o mal:
type Resultado<T> = { ok: true; valor: T } | { ok: false; error: string };
function dividir(a: number, b: number): Resultado<number> {
if (b === 0) return { ok: false, error: "División entre cero" };
return { ok: true, valor: a / b };
}
const r = dividir(10, 4);
if (r.ok) {
console.log(r.valor); // 2.5
} else {
console.log(r.error);
}
Es una unión discriminada genérica: comprobar r.ok estrecha el tipo y te deja usar valor o error según el caso.
Clases genéricas
Una clase genérica declara el parámetro de tipo junto a su nombre y lo usa en propiedades y métodos. El ejemplo típico es un contenedor:
class Caja<T> {
#contenido: T[] = [];
meter(item: T): void {
this.#contenido.push(item);
}
sacar(): T | undefined {
return this.#contenido.pop();
}
get cantidad(): number {
return this.#contenido.length;
}
}
const mochila = new Caja<string>();
mochila.meter("cantimplora");
mochila.meter("chubasquero");
console.log(mochila.sacar()); // chubasquero
console.log(mochila.cantidad); // 1
mochila.meter(3);
// error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
Cada Caja solo admite el tipo con el que se creó. Si haces new Caja<number>(), tendrás una caja de números con los mismos métodos. Si ya estudiaste genéricos en Java, es exactamente la misma idea.
Valores por defecto
Un parámetro de tipo puede tener un valor por defecto, que se usa cuando no se indica ni se puede inferir:
interface Paginado<T = string> {
pagina: number;
elementos: T[];
}
const etiquetas: Paginado = { pagina: 1, elementos: ["montaña", "playa"] };
const notas: Paginado<number> = { pagina: 2, elementos: [8, 6.5] };
console.log(etiquetas.elementos.length, notas.elementos[1]); // 2 6.5
Se pueden combinar restricción y valor por defecto: <T extends ConId = Socio>.
Cuándo usar genéricos
- Cuando la función o clase hace lo mismo con distintos tipos y el tipo de salida depende del de entrada.
- No los uses si el parámetro de tipo aparece una sola vez.
function mostrar<T>(x: T): voidno aporta nada frente a(x: unknown): void. Un genérico tiene sentido cuando relaciona dos cosas: un argumento con otro, o un argumento con el resultado.
Errores frecuentes
- Usar
anyen lugar de un genérico: funciona, pero apaga las comprobaciones. Si la salida depende de la entrada, es un trabajo para<T>. - Acceder a propiedades de
Tsin restringirlo:Tpuede ser cualquier cosa, así que TypeScript no te deja usar.lengtho.id. Añadeextends. - Pensar que
extendsen un genérico es herencia: solo significa “tiene que ser compatible con”. - Escribir el tipo cuando se infiere solo:
ultimo<number>([1, 2])es ruido. Ponlo solo cuando no haya de dónde deducirlo. - Llenar el código de parámetros de tipo: si no relacionan nada, sobran.
Resumen
| Concepto | Ejemplo |
|---|---|
| Función genérica | function ultimo<T>(lista: T[]): T | undefined |
| Tipo explícito | crearLista<string>() |
| Varios parámetros | function emparejar<A, B>(a: A, b: B): [A, B] |
| Restricción | <T extends { length: number }> |
| Clave válida de un objeto | <T, K extends keyof T> |
| Tipo indexado | T[K]: tipo de la propiedad K de T |
| Interfaz genérica | interface Respuesta<T> { datos: T } |
| Clase genérica | class Caja<T> { meter(item: T) {} } |
| Valor por defecto | interface Paginado<T = string> |
| Genéricos que ya conoces | Array<T>, Promise<T>, Map<K, V>, Set<T> |
En la siguiente lección conocerás los tipos utilitarios, genéricos que vienen incluidos en TypeScript para transformar unos tipos en otros.
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.