Tipos utilitarios en TypeScript
En esta lección
- Antes de empezar: keyof, typeof y tipos indexados
- Partial: todo opcional
- Required: todo obligatorio
- Readonly: solo lectura
- Pick y Omit: elegir propiedades
- Record: objetos como diccionarios
- Utilitarios para uniones: Exclude, Extract y NonNullable
- Utilitarios para funciones: ReturnType, Parameters y Awaited
- Por dentro: tipos mapeados
- Errores frecuentes
- Resumen
En un proyecto real aparecen muchas versiones del mismo tipo: el producto completo que guarda la base de datos, el producto sin id que envía el formulario de alta, el producto con todos los campos opcionales para editarlo… Escribir cada versión a mano es repetitivo y, peor, se desincronizan en cuanto cambias el original. Los tipos utilitarios (utility types) son genéricos que vienen incluidos en TypeScript y que transforman un tipo en otro. En esta lección verás los más usados, cuándo conviene cada uno y cómo están hechos por dentro.
Antes de empezar: keyof, typeof y tipos indexados
Los tipos utilitarios se apoyan en tres operadores que ya has visto de pasada en enums y genéricos. Un repaso rápido:
const tarifa = { nombre: "Mensual", precio: 39, sesiones: 12 };
type Tarifa = typeof tarifa; // { nombre: string; precio: number; sesiones: number }
type Campo = keyof Tarifa; // "nombre" | "precio" | "sesiones"
type Precio = Tarifa["precio"]; // number
const campo: Campo = "sesiones";
console.log(tarifa[campo]); // 12
typeof valorusado en un tipo obtiene el tipo de una variable que ya existe. No es eltypeofde JavaScript que devuelve un texto: aquí trabaja en el mundo de los tipos.keyof Tipoda la unión de los nombres de sus propiedades.Tipo["propiedad"]es un tipo indexado: el tipo de esa propiedad. También acepta uniones:Tarifa["nombre" | "precio"]esstring | number.
Con esto en mente, vamos con los utilitarios. Usaremos este tipo de una tienda online durante toda la lección:
interface Producto {
id: number;
nombre: string;
precio: number;
stock: number;
descripcion?: string;
}
Partial: todo opcional
Partial<T> crea un tipo igual a T pero con todas las propiedades opcionales. Es perfecto para actualizaciones, donde solo envías los campos que cambian:
interface Producto {
id: number;
nombre: string;
precio: number;
stock: number;
descripcion?: string;
}
function actualizar(producto: Producto, cambios: Partial<Producto>): Producto {
return { ...producto, ...cambios };
}
const botas: Producto = { id: 7, nombre: "Botas de montaña", precio: 89, stock: 5 };
const rebajadas = actualizar(botas, { precio: 69 });
console.log(rebajadas.precio, rebajadas.stock); // 69 5
actualizar(botas, { precio: "barato" });
// error TS2322: Type 'string' is not assignable to type 'number'.
cambios puede traer uno, varios o ningún campo, pero los que traiga tienen que ser del tipo correcto. Sin Partial tendrías que escribir una interfaz CambiosProducto copiando cada propiedad con ?.
Required: todo obligatorio
Required<T> hace lo contrario: quita los ? y vuelve obligatorias todas las propiedades. Es útil para opciones de configuración, que el usuario pasa incompletas pero que tú completas con valores por defecto:
interface OpcionesListado {
porPagina?: number;
orden?: "precio" | "nombre";
soloConStock?: boolean;
}
const porDefecto: Required<OpcionesListado> = {
porPagina: 20,
orden: "nombre",
soloConStock: false,
};
function prepararListado(opciones: OpcionesListado): Required<OpcionesListado> {
return { ...porDefecto, ...opciones };
}
const config = prepararListado({ orden: "precio" });
console.log(config.porPagina, config.orden); // 20 precio
Dentro del programa trabajas con config, que tiene todas las opciones garantizadas: no hace falta comprobar si porPagina es undefined cada vez que la usas.
Readonly: solo lectura
Readonly<T> marca todas las propiedades como readonly. Así una función promete que no modificará el objeto que recibe:
interface Carrito {
articulos: number;
total: number;
}
function mostrarTotal(carrito: Readonly<Carrito>): string {
carrito.total = 0;
// error TS2540: Cannot assign to 'total' because it is a read-only property.
return `${carrito.articulos} artículos: ${carrito.total} €`;
}
Para arrays existe la versión equivalente ReadonlyArray<T> (o readonly T[]), que quita push, pop, sort y el resto de métodos que modifican:
function mejorMarca(marcas: readonly number[]): number {
return Math.min(...marcas);
}
const marcas = [52.3, 49.8, 50.1];
console.log(mejorMarca(marcas)); // 49.8
Cuidado:
Readonlysolo afecta al primer nivel. Si una propiedad es un objeto, sus propiedades internas se pueden seguir cambiando. Y recuerda que es solo una comprobación del compilador: al ejecutar el objeto no está congelado. Para eso existeObject.freeze, que además TypeScript tipa comoReadonly.
Pick y Omit: elegir propiedades
Pick<T, Claves> crea un tipo con solo las propiedades que indiques. Omit<T, Claves> hace lo contrario: copia todas menos las indicadas. Las claves se escriben como una unión de textos:
interface Producto {
id: number;
nombre: string;
precio: number;
stock: number;
descripcion?: string;
}
type ResumenProducto = Pick<Producto, "nombre" | "precio">;
// { nombre: string; precio: number }
type NuevoProducto = Omit<Producto, "id">;
// { nombre: string; precio: number; stock: number; descripcion?: string }
function tarjeta(p: ResumenProducto): string {
return `${p.nombre} - ${p.precio} €`;
}
let siguienteId = 1;
function crear(datos: NuevoProducto): Producto {
return { id: siguienteId++, ...datos };
}
const mochila = crear({ nombre: "Mochila 30 L", precio: 45, stock: 12 });
console.log(mochila.id); // 1
console.log(tarjeta(mochila)); // Mochila 30 L - 45 €
Fíjate en dos detalles:
tarjetarecibe unResumenProducto, pero le pasas unProductocompleto y no protesta. TypeScript compara por forma: un producto tienenombreyprecio, así que vale.NuevoProductoes lo que envía un formulario de alta: todavía no tieneid, porque lo asigna el servidor o la base de datos. Si mañana añadescategoriaaProducto,NuevoProductola incluye solo.
¿Cuál usar? Si quieres pocas propiedades, Pick. Si quieres casi todas, Omit. Hay una diferencia importante: Pick solo acepta claves que existan, mientras que Omit acepta cualquier texto. Una errata en Omit<Producto, "idd"> no da error y simplemente no quita nada.
Combinar utilitarios
Como cada utilitario devuelve un tipo, se pueden anidar. Un caso muy habitual: los datos para editar un producto son “cualquier campo, menos el id, que no se puede cambiar”:
interface Producto {
id: number;
nombre: string;
precio: number;
stock: number;
}
type CambiosProducto = Partial<Omit<Producto, "id">>;
const cambio: CambiosProducto = { stock: 0 };
console.log(cambio); // { stock: 0 }
const trampa: CambiosProducto = { id: 99 };
// error TS2353: Object literal may only specify known properties, and 'id' does not exist in type 'Partial<Omit<Producto, "id">>'.
Se leen de dentro afuera: primero quita id, luego hace todo opcional.
Record: objetos como diccionarios
Record<Claves, Valor> describe un objeto cuyas claves son de un tipo y cuyos valores son todos de otro. Tiene dos usos.
Con una unión de literales como claves, obliga a que estén todas. Es ideal para tablas de traducción o configuración:
type Talla = "S" | "M" | "L" | "XL";
const stockPorTalla: Record<Talla, number> = {
S: 4,
M: 10,
L: 7,
XL: 0,
};
const etiquetas: Record<Talla, string> = {
S: "Pequeña",
M: "Mediana",
L: "Grande",
};
// error TS2741: Property 'XL' is missing in type '{ S: string; M: string; L: string; }' but required in type 'Record<Talla, string>'.
console.log(stockPorTalla.M); // 10
Si añades una talla a la unión, TypeScript te señala cada Record que se ha quedado sin ella.
Con string como clave, describe un diccionario abierto, con las claves que sean:
const votos: Record<string, number> = {};
for (const color of ["rojo", "azul", "rojo", "verde", "rojo"]) {
votos[color] = (votos[color] ?? 0) + 1;
}
console.log(votos); // { rojo: 3, azul: 1, verde: 1 }
Nota:
Record<string, number>equivale a la firma de índice{ [clave: string]: number }, que verás en código más antiguo. Para un diccionario que cambia mucho durante el programa, unMapsuele ser mejor opción.
Utilitarios para uniones: Exclude, Extract y NonNullable
Estos trabajan sobre uniones en lugar de objetos:
Exclude<Union, Quitar>: quita de la unión los tipos indicados.Extract<Union, Dejar>: se queda solo con los indicados.NonNullable<T>: quitanullyundefined.
type EstadoPedido = "pendiente" | "pagado" | "enviado" | "entregado" | "cancelado";
type EstadoActivo = Exclude<EstadoPedido, "entregado" | "cancelado">;
// "pendiente" | "pagado" | "enviado"
type EstadoCerrado = Extract<EstadoPedido, "entregado" | "cancelado">;
// "entregado" | "cancelado"
type Telefono = string | null | undefined;
type TelefonoSeguro = NonNullable<Telefono>; // string
function puedeCancelar(estado: EstadoPedido): estado is EstadoActivo {
return estado !== "entregado" && estado !== "cancelado";
}
console.log(puedeCancelar("enviado")); // true
console.log(puedeCancelar("entregado")); // false
No confundas Exclude con Omit: Omit quita propiedades de un objeto, Exclude quita miembros de una unión. La función puedeCancelar es un predicado de tipo, como los que viste en estrechar tipos.
Utilitarios para funciones: ReturnType, Parameters y Awaited
Estos sacan información de funciones que ya existen, algo muy útil cuando la función es de una librería y no exporta sus tipos:
function crearSesion(usuario: string, minutos: number) {
return {
usuario,
caduca: Date.now() + minutos * 60_000,
rol: "socio" as const,
};
}
type Sesion = ReturnType<typeof crearSesion>;
// { usuario: string; caduca: number; rol: "socio" }
type ArgsSesion = Parameters<typeof crearSesion>;
// [usuario: string, minutos: number]
const args: ArgsSesion = ["irene", 30];
const sesion: Sesion = crearSesion(...args);
console.log(sesion.usuario, sesion.rol); // irene socio
Fíjate en typeof crearSesion: los utilitarios reciben tipos, y crearSesion es un valor (una función), así que primero hay que obtener su tipo.
Para funciones asíncronas, ReturnType da una Promise<...>. Awaited<T> quita la promesa y da el tipo que obtendrías con await:
async function cargarRutas() {
return [
{ nombre: "Senda costera", km: 14 },
{ nombre: "Pico Castro Valnera", km: 11 },
];
}
type Rutas = Awaited<ReturnType<typeof cargarRutas>>;
// { nombre: string; km: number }[]
function totalKm(rutas: Rutas): number {
return rutas.reduce((suma, r) => suma + r.km, 0);
}
cargarRutas().then((rutas) => console.log(totalKm(rutas))); // 25
Consejo: derivar tipos de funciones es práctico, pero no abuses. Si un tipo es importante en tu programa (
Sesion,Ruta), es más claro declararlo con su nombre en una interfaz y usarlo en la función.ReturnTypebrilla con código que no controlas.
Por dentro: tipos mapeados
Los tipos utilitarios no son magia: están escritos en TypeScript con tipos mapeados, una sintaxis que recorre las claves de un tipo con in, como un bucle for...in en el mundo de los tipos. Así se podría escribir un Partial propio:
type Opcional<T> = {
[K in keyof T]?: T[K];
};
interface Ruta {
nombre: string;
km: number;
}
const borrador: Opcional<Ruta> = { nombre: "Ruta de los Molinos" };
console.log(borrador); // { nombre: 'Ruta de los Molinos' }
Se lee así: “para cada clave K de T, crea una propiedad opcional con el mismo tipo que tenía, T[K]”. Cambiando el modificador obtienes los demás: readonly [K in keyof T]: T[K] es Readonly, y [K in keyof T]-?: T[K] (con -?, que quita el opcional) es Required.
También puedes cambiar el tipo de los valores. Este tipo crea, para cualquier formulario, un objeto de errores con un texto por campo:
interface FormularioAlta {
nombre: string;
email: string;
edad: number;
}
type Errores<T> = {
[K in keyof T]?: string;
};
const errores: Errores<FormularioAlta> = {
email: "Formato no válido",
edad: "Debes ser mayor de edad",
};
console.log(Object.keys(errores)); // [ 'email', 'edad' ]
Los utilitarios como Exclude o ReturnType usan otra herramienta, los tipos condicionales (T extends U ? X : Y), que funcionan como un operador ternario entre tipos. Son un tema avanzado: con saber que existen y reconocerlos al leer las definiciones (en VS Code, F12 sobre Exclude) es suficiente por ahora.
Errores frecuentes
- Escribir a mano las variantes de un tipo: si copias
ProductoenNuevoProductoquitando elid, al añadir un campo tendrás que acordarte de cambiar las dos. UsaOmityPartial. - Confundir
OmitconExclude:Omittrabaja con objetos y sus propiedades;Exclude, con uniones. - Erratas en
Omit: acepta cualquier texto como clave, así queOmit<Producto, "idd">no quita nada y no avisa. Revisa los nombres o usaPickcuando sean pocas. - Pasar un valor en lugar de un tipo:
ReturnType<crearSesion>da error; hace faltaReturnType<typeof crearSesion>. - Creer que
Readonlycongela el objeto: solo lo comprueba el compilador, y solo en el primer nivel. En ejecución usaObject.freezesi lo necesitas. - Abusar de
Partial: si una función necesita de verdad elprecio, no la tipes conPartial<Producto>, o tendrás que comprobarundefineden todas partes.
Resumen
| Utilitario | Qué hace | Ejemplo de uso |
|---|---|---|
Partial<T> | Todas las propiedades opcionales | Datos de una actualización |
Required<T> | Todas obligatorias | Opciones ya completadas |
Readonly<T> | Todas de solo lectura | Parámetros que no se modifican |
Pick<T, K> | Solo las propiedades K | Resúmenes, tarjetas |
Omit<T, K> | Todas menos K | Datos de alta sin id |
Record<K, V> | Objeto con claves K y valores V | Tablas por talla, contadores |
Exclude<U, X> | Quita miembros de una unión | Estados activos |
Extract<U, X> | Deja solo esos miembros | Estados cerrados |
NonNullable<T> | Quita null y undefined | Valores ya comprobados |
ReturnType<F> | Tipo que devuelve una función | Tipos de librerías |
Parameters<F> | Tupla con los parámetros | Reenviar argumentos |
Awaited<T> | Quita la Promise | Resultado de funciones async |
| Tipo mapeado | { [K in keyof T]?: T[K] } | Crear tus propios utilitarios |
En la siguiente lección verás cómo repartir el código en varios archivos y configurar el compilador a fondo: módulos y tsconfig.
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.