Genéricos en TypeScript

Lección 6 de 8. Los genéricos en TypeScript son tipos que llevan un hueco para otro tipo. Gracias a ellos, map sabe que un array de números da otro array del tipo que devuelvas, y una función de búsqueda devuelve exactamente lo que buscabas, no un any. Aquí aprenderás a escribirlos y a usar los tipos utilitarios que vienen de serie.

De la primera función genérica a todo lo demás

En la lección 4 escribiste primero<T>, una función que sirve para arrays de cualquier tipo y devuelve el tipo correcto en cada llamada. Esa idea, un tipo que es un parámetro, está por todas partes: Array<T>, Promise<T>, Map<K, V> son genéricos que ya has usado sin llamarlos así. Una función puede tener varios parámetros de tipo:

function emparejar<A, B>(a: A, b: B): [A, B] {
  return [a, b];
}

const p = emparejar("edad", 42);          // [string, number]
const [clave, valor] = p;
console.log(clave.toUpperCase(), valor.toFixed(1));

const indice = new Map<string, number[]>();   // también se pueden escribir a mano
indice.set("pares", [2, 4, 6]);
console.log(indice.get("pares")?.length);

Ábrelo en el editor y cámbialo →

salidaEDAD 42.0
3

Restricciones: T, pero no cualquiera

Dentro de una función genérica, T puede ser cualquier cosa, así que no puedes usar nada específico suyo. Si necesitas, por ejemplo, su propiedad length, hay que restringir T con extends: «T puede ser cualquier tipo que tenga un length numérico»:

function masLargo<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}

console.log(masLargo("casa", "murciélago"));   // T = string
console.log(masLargo([1, 2, 3], [4, 5]));       // T = number[]
console.log(masLargo(10, 200));                 // error: un número no tiene length

Ábrelo en el editor y cámbialo →

tsc --strictrestriccion.ts(7,22): error TS2345: No se puede asignar un argumento de tipo "number" al parámetro de tipo "{ length: number; }".

Es el tipado estructural de la lección 5 otra vez: a la restricción no le importa si es un texto o un array, solo que tenga length.

keyof y el acceso indexado

keyof T es la unión de los nombres de las propiedades de T, y T[K] es el tipo de la propiedad K. Juntos permiten escribir una función que lee una propiedad de un objeto y devuelve el tipo exacto de esa propiedad, y que no acepta claves que no existen:

interface Usuario {
  id: number;
  nombre: string;
  email: string;
}

function obtener<T, K extends keyof T>(obj: T, clave: K): T[K] {
  return obj[clave];
}

const u: Usuario = { id: 1, nombre: "Ana", email: "ana@ejemplo.com" };
const id = obtener(u, "id");          // number
const nombre = obtener(u, "nombre");  // string
console.log(id + 1, nombre.toUpperCase());

type Email = Usuario["email"];        // string: el tipo de una propiedad
obtener(u, "nombr");                  // error

Ábrelo en el editor y cámbialo →

tsc --strictkeyof.ts(17,12): error TS2345: No se puede asignar un argumento de tipo ""nombr"" al parámetro de tipo "keyof Usuario".

Interfaces, tipos y clases genéricos

Los parámetros de tipo no son solo para funciones. Un tipo genérico describe una forma que se repite con contenidos distintos, como la respuesta de una API que a veces trae usuarios y a veces pedidos. Y pueden tener un valor por omisión, como los parámetros de una función:

type Respuesta<T> =
  | { ok: true; datos: T }
  | { ok: false; error: string };

interface Pagina<T = string> {
  elementos: T[];
  total: number;
}

function mostrar(r: Respuesta<number[]>) {
  if (r.ok) console.log("suma:", r.datos.reduce((a, b) => a + b, 0));
  else console.log("error:", r.error);
}

mostrar({ ok: true, datos: [3, 4, 5] });
mostrar({ ok: false, error: "tiempo agotado" });

const nombres: Pagina = { elementos: ["Ana", "Luis"], total: 2 };   // T = string
console.log(nombres.elementos.join(", "));

Ábrelo en el editor y cámbialo →

salidasuma: 12
error: tiempo agotado
Ana, Luis

Fíjate en que Respuesta<T> es la unión discriminada de la lección 3, ahora reutilizable para cualquier tipo de datos. Las clases funcionan igual: el parámetro de tipo se fija al crear el objeto.

class Pila<T> {
  #elementos: T[] = [];

  apilar(x: T): void { this.#elementos.push(x); }
  desapilar(): T | undefined { return this.#elementos.pop(); }
  get tamano(): number { return this.#elementos.length; }
}

const deshacer = new Pila<string>();
deshacer.apilar("escribir 'hola'");
deshacer.apilar("borrar línea");
console.log(deshacer.desapilar(), "| quedan", deshacer.tamano);
deshacer.apilar(42);   // error: la pila es de textos

Ábrelo en el editor y cámbialo →

tsc --strictpila.ts(13,17): error TS2345: No se puede asignar un argumento de tipo "number" al parámetro de tipo "string".

Los tipos utilitarios

TypeScript trae de serie unos cuantos tipos genéricos que fabrican tipos nuevos a partir de otros. No hace falta memorizarlos todos; estos son los que se ven en cualquier proyecto:

A partir del tipo Usuario, Partial lo vuelve todo opcional, Pick se queda con id y nombre, Omit quita email y keyof da la unión de sus clavesUn tipo de partida y lo que sacan de él los tipos utilitariosUsuarioid: numbernombre: stringemail: stringedad?: numberPartial<Usuario>id?: numbernombre?: stringemail?: stringedad?: numberPick<Usuario, "id" | "nombre">id: numbernombre: stringOmit<Usuario, "email">id: numbernombre: stringedad?: numberkeyof Usuario"id"| "nombre"| "email"| "edad"Ninguno copia el tipo: si Usuario cambia, todos cambian con él.

Los utilitarios derivan tipos nuevos de uno existente, así que no hay que copiar ni mantener dos definiciones. Esquema: decodigo.com.
UtilitarioQué hacePara qué se usa
Partial<T>todas las propiedades opcionaleslos cambios de un «actualizar»
Required<T>todas obligatoriasuna configuración ya completada
Readonly<T>todas de solo lecturaestado que no se debe modificar
Pick<T, K>solo las propiedades Klo que se muestra en una lista
Omit<T, K>todas menos Kun usuario sin la contraseña
Record<K, V>un objeto con claves K y valores Vdiccionarios y tablas de búsqueda
ReturnType<F>el tipo que devuelve la función Freutilizar el tipo de algo que no exportan

El error que te ahorra: una errata en el nombre de un campo al actualizar

function actualizar(usuario, cambios) {
  return { ...usuario, ...cambios };
}

const u = { id: 1, nombre: "Ana", email: "ana@ejemplo.com" };
const nuevo = actualizar(u, { emial: "ana@empresa.com" });
console.log(nuevo.email);
node actualizar_antes.mjsana@ejemplo.com

No hay error: el objeto tiene ahora un campo emial nuevo y el email de siempre, sin cambiar. El usuario sigue recibiendo los correos en la dirección antigua y nadie sabe por qué. Con Partial<Usuario>, los cambios solo pueden ser campos de Usuario:

interface Usuario { id: number; nombre: string; email: string }

function actualizar(usuario: Usuario, cambios: Partial<Usuario>): Usuario {
  return { ...usuario, ...cambios };
}

const u: Usuario = { id: 1, nombre: "Ana", email: "ana@ejemplo.com" };
const nuevo = actualizar(u, { emial: "ana@empresa.com" });
tsc --strictactualizar.ts(8,31): error TS2561: El literal de objeto solo puede especificar propiedades conocidas, pero "emial" no existe en el tipo "Partial<Usuario>". ¿Quería escribir "email"?

Y así se ven los demás en acción:

interface Usuario {
  id: number;
  nombre: string;
  email: string;
  clave: string;
}

type UsuarioPublico = Omit<Usuario, "clave">;
type Fila = Pick<Usuario, "id" | "nombre">;
type Rol = "admin" | "editor" | "lector";

const permisos: Record<Rol, string[]> = {
  admin: ["leer", "escribir", "borrar"],
  editor: ["leer", "escribir"],
  lector: ["leer"],
};

function crearUsuario(nombre: string) {
  return { id: Date.now(), nombre, activo: true };
}
type Creado = ReturnType<typeof crearUsuario>;   // { id: number; nombre: string; activo: boolean }

const publico: UsuarioPublico = { id: 1, nombre: "Ana", email: "ana@ejemplo.com" };
const fila: Fila = { id: publico.id, nombre: publico.nombre };
const c: Creado = { id: 2, nombre: "Luis", activo: false };
console.log(fila, permisos.editor, c.activo);

Ábrelo en el editor y cámbialo →

salida{ id: 1, nombre: 'Ana' } [ 'leer', 'escribir' ] false

Dos detalles. En Record<Rol, string[]>, si olvidas uno de los tres roles, el compilador se queja: un Record con claves de una unión exige todas. Y typeof crearUsuario dentro de un tipo no es el typeof de JavaScript, sino el de TypeScript: «el tipo de esta variable». Cómo se construyen estos utilitarios por dentro (son unas pocas líneas cada uno) es el tema de la lección 7.

Promesas: el genérico de cada día

Una función async siempre devuelve una promesa, y su tipo es Promise<T>, donde T es lo que obtienes con await. Las promesas y async/await están explicados en la lección de errores y asincronía de JavaScript; aquí solo cambia la anotación:

interface Tarea { id: number; hecha: boolean }

function esperar(ms: number): Promise<void> {
  return new Promise((resolver) => setTimeout(resolver, ms));
}

async function cargarTareas(): Promise<Tarea[]> {
  await esperar(50);                       // simula una petición a un servidor
  return [{ id: 1, hecha: true }, { id: 2, hecha: false }];
}

const tareas = await cargarTareas();       // Tarea[]
console.log(tareas.filter((t) => !t.hecha).length, "pendiente(s)");

Ábrelo en el editor y cámbialo →

salida1 pendiente(s)
¿Cuándo escribir un genérico? Cuando el mismo código sirve para varios tipos y quieres conservar el tipo concreto en la salida. Si una función genérica usa T una sola vez (por ejemplo, function f<T>(x: T): void), el genérico no aporta nada: unknown haría lo mismo y se lee mejor.

Resumen

ParaEscribeRecuerda
Una función para cualquier tipof<T>(x: T): TTypeScript deduce T
Que T tenga algoT extends { length: number }basta con la forma
Las claves de un tipokeyof Tuna unión de textos literales
El tipo de una propiedadT[K], Usuario["email"]—
Un tipo con huecotype Respuesta<T> = ...admite valor por omisión
Derivar tiposPartial, Pick, Omit, Recordno copies tipos a mano
Una función asíncrona: Promise<T>await da T

Retos

Reto 1. Escribe agrupar<T, K extends keyof T>(lista: T[], clave: K) que devuelva un Map del valor de esa clave a los elementos que lo tienen. Pruébala agrupando productos por categoría.

Ver la solución
function agrupar<T, K extends keyof T>(lista: T[], clave: K): Map<T[K], T[]> {
  const grupos = new Map<T[K], T[]>();
  for (const x of lista) {
    const k = x[clave];
    grupos.set(k, [...(grupos.get(k) ?? []), x]);
  }
  return grupos;
}

const productos = [
  { nombre: "Taza", categoria: "cocina" },
  { nombre: "Lámpara", categoria: "salón" },
  { nombre: "Sartén", categoria: "cocina" },
];
for (const [cat, ps] of agrupar(productos, "categoria")) {
  console.log(cat, ps.map((p) => p.nombre));
}

Ábrelo en el editor y cámbialo →

salidacocina [ 'Taza', 'Sartén' ]
salón [ 'Lámpara' ]

Reto 2. Define un tipo NuevoUsuario a partir de Usuario (con id, nombre, email y creado: Date) sin el id ni la fecha, que los pone el servidor, y con el email opcional. Pista: combina Omit, Partial, Pick y &.

Ver la solución
interface Usuario { id: number; nombre: string; email: string; creado: Date }

type NuevoUsuario = Omit<Usuario, "id" | "creado" | "email"> & Partial<Pick<Usuario, "email">>;

const a: NuevoUsuario = { nombre: "Ana" };
const b: NuevoUsuario = { nombre: "Luis", email: "luis@ejemplo.com" };
const c: NuevoUsuario = { nombre: "Eva", id: 3 };   // error: el id no se permite

Ábrelo en el editor y cámbialo →

tsc --strictreto2.ts(7,42): error TS2353: El literal de objeto solo puede especificar propiedades conocidas y 'id' no existe en el tipo 'NuevoUsuario'.

Reto 3. Esta función genérica no compila. ¿Por qué, y cómo se arregla? function sumarTodo<T>(lista: T[]): number { return lista.reduce((a, b) => a + b, 0); }

Ver la solución
function sumarTodo<T>(lista: T[]): number {
  return lista.reduce((a, b) => a + b, 0);
}

Ábrelo en el editor y cámbialo →

tsc --strictreto3.ts(2,33): error TS2365: El operador '+' no se puede aplicar a los tipos 'number' y 'T'.

Sin restricción, T podría ser cualquier cosa, y no se puede sumar cualquier cosa. Aquí no hace falta un genérico: basta con lista: number[]. Es el caso de la nota de arriba: si T no sirve para nada, sobra.

Todo el código de esta lección se comprobó con tsc 7.0.2 en modo estricto (--strict --locale es) y se ejecutó con Node.js 24.22 en un contenedor limpio antes de publicarla; los mensajes de error y las salidas están copiados de esa ejecución. ¿Quieres probar algo? Usa el editor de TypeScript.