Saltar al contenido
tipos-utilitarios.ts · devschool

Tipos utilitarios en TypeScript

Lección 12 de 15 · 12 min de lectura · Actualizado el

En esta lección
  1. Antes de empezar: keyof, typeof y tipos indexados
  2. Partial: todo opcional
  3. Required: todo obligatorio
  4. Readonly: solo lectura
  5. Pick y Omit: elegir propiedades
  6. Record: objetos como diccionarios
  7. Utilitarios para uniones: Exclude, Extract y NonNullable
  8. Utilitarios para funciones: ReturnType, Parameters y Awaited
  9. Por dentro: tipos mapeados
  10. Errores frecuentes
  11. 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 valor usado en un tipo obtiene el tipo de una variable que ya existe. No es el typeof de JavaScript que devuelve un texto: aquí trabaja en el mundo de los tipos.
  • keyof Tipo da 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"] es string | 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: Readonly solo 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 existe Object.freeze, que además TypeScript tipa como Readonly.

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:

  • tarjeta recibe un ResumenProducto, pero le pasas un Producto completo y no protesta. TypeScript compara por forma: un producto tiene nombre y precio, así que vale.
  • NuevoProducto es lo que envía un formulario de alta: todavía no tiene id, porque lo asigna el servidor o la base de datos. Si mañana añades categoria a Producto, NuevoProducto la 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, un Map suele 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>: quita null y undefined.
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. ReturnType brilla 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 Producto en NuevoProducto quitando el id, al añadir un campo tendrás que acordarte de cambiar las dos. Usa Omit y Partial.
  • Confundir Omit con Exclude: Omit trabaja con objetos y sus propiedades; Exclude, con uniones.
  • Erratas en Omit: acepta cualquier texto como clave, así que Omit<Producto, "idd"> no quita nada y no avisa. Revisa los nombres o usa Pick cuando sean pocas.
  • Pasar un valor en lugar de un tipo: ReturnType<crearSesion> da error; hace falta ReturnType<typeof crearSesion>.
  • Creer que Readonly congela el objeto: solo lo comprueba el compilador, y solo en el primer nivel. En ejecución usa Object.freeze si lo necesitas.
  • Abusar de Partial: si una función necesita de verdad el precio, no la tipes con Partial<Producto>, o tendrás que comprobar undefined en todas partes.

Resumen

UtilitarioQué haceEjemplo de uso
Partial<T>Todas las propiedades opcionalesDatos de una actualización
Required<T>Todas obligatoriasOpciones ya completadas
Readonly<T>Todas de solo lecturaParámetros que no se modifican
Pick<T, K>Solo las propiedades KResúmenes, tarjetas
Omit<T, K>Todas menos KDatos de alta sin id
Record<K, V>Objeto con claves K y valores VTablas por talla, contadores
Exclude<U, X>Quita miembros de una uniónEstados activos
Extract<U, X>Deja solo esos miembrosEstados cerrados
NonNullable<T>Quita null y undefinedValores ya comprobados
ReturnType<F>Tipo que devuelve una funciónTipos de librerías
Parameters<F>Tupla con los parámetrosReenviar argumentos
Awaited<T>Quita la PromiseResultado 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

[TypeScript] ¿Qué permite el parámetro cambios: Partial<Producto>?
function actualizar(producto: Producto, cambios: Partial<Producto>): Producto {
  return { ...producto, ...cambios };
}

[TypeScript] Quieres el tipo de un producto sin la propiedad id. ¿Qué utilitario usas?

[TypeScript] ¿Qué error da TypeScript en etiquetas?
type Talla = "S" | "M" | "L" | "XL";
const etiquetas: Record<Talla, string> = {
  S: "Pequeña",
  M: "Mediana",
  L: "Grande",
};

[TypeScript] ¿Qué falla en este código?
function crearSesion(usuario: string) {
  return { usuario, activa: true };
}
type Sesion = ReturnType<crearSesion>;

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.