learning-front

Nivel 5 · TypeScript: JavaScript con red de seguridad

Utility types

Partial, Pick, Omit, Record, Readonly y ReturnType: transformar y derivar tipos a partir de otros sin repetirte.

Hasta ahora has escrito interfaces y types a mano, campo por campo. Eso funciona, pero tiene un coste: si el tipo base cambia, tienes que actualizar cada derivado a mano. Los utility types resuelven eso: son tipos genéricos que TypeScript incluye de serie y que transforman o derivan tipos a partir de otros.

¿Qué es un utility type?#

Un utility type es un tipo genérico de la librería estándar de TypeScript. Lo aplicas sobre un tipo que ya tienes —una interface, un type alias— y obtienes uno nuevo, transformado de alguna forma concreta: con todos los campos opcionales, con solo algunos, sin algunos, de solo lectura, etc.

typescript
// Sin utility types: tienes que escribir una segunda forma a mano.
// Si Heroe cambia, ActualizacionHeroe queda desincronizado.
interface ActualizacionHeroeMal {
  nombre?: string;
  rol?: string;
  partidas?: number;
  victorias?: number;
}

// Con utility types: se deriva de Heroe, se actualiza solo.
// Una sola fuente de verdad.
type ActualizacionHeroeBien = Partial<Heroe>;

La clave es la única fuente de verdad: defines Heroe una vez y el resto de tipos se derivan de ella. Si mañana añades un campo a Heroe, todos los utility types que la usan lo recogen solos.

Partial<T>#

Partial<T> convierte todos los campos de T en opcionales. Es el utility correcto cuando solo quieres actualizar una parte de un registro: en vez de exigir los cuatro campos, la función acepta cualquier subconjunto.

typescript
// Partial<Heroe>: todos los campos con "?".
// Equivale a escribir { nombre?: string; rol?: string; ... } a mano,
// pero derivado de Heroe: si Heroe cambia, Partial lo refleja solo.
function actualizarHeroe(base: Heroe, cambios: Partial<Heroe>): Heroe {
  return { ...base, ...cambios };
}

// Solo mandas los campos que cambian.
const tracerActualizado = actualizarHeroe(tracer, { partidas: 125 });

Required<T>#

Required<T> es el inverso de Partial: convierte todos los campos en obligatorios, aunque el tipo original los tuviera como opcionales. Útil cuando quieres modelar un estado “borrador” (campos opcionales) y un estado “enviado” (todos obligatorios).

typescript
// BorradorHeroe: todos opcionales mientras se rellena.
interface BorradorHeroe {
  nombre?: string;
  rol?: string;
  partidas?: number;
  victorias?: number;
}

// Required<BorradorHeroe>: al enviar, todos obligatorios.
type HeroeCompleto = Required<BorradorHeroe>;

Pick<T, K>#

Pick<T, K> construye un tipo nuevo con solo los campos K de T. Lo usas cuando una vista o función solo necesita una parte del tipo completo: no copias la forma a mano, la derivas de la fuente original.

typescript
// Solo necesitas nombre y rol para una tarjeta de vista rápida.
// Pick<Heroe, "nombre" | "rol"> garantiza que el tipo derivado
// solo tiene esos dos campos: ni más ni menos.
function tarjetaHeroe(h: Heroe): Pick<Heroe, "nombre" | "rol"> {
  return { nombre: h.nombre, rol: h.rol };
}

Omit<T, K>#

Omit<T, K> es el reverso de Pick: construye un tipo con todos los campos de T excepto los de K. En vez de elegir qué conservar, eliges qué quitar.

typescript
// Al registrar un héroe nuevo, aún no tiene estadísticas.
// Omit<Heroe, "partidas" | "victorias"> deja solo nombre y rol.
type FichaPublica = Omit<Heroe, "partidas" | "victorias">;

function registrarHeroe(ficha: FichaPublica): void {
  console.log("Registrado: " + ficha.nombre + " (" + ficha.rol + ")");
}

La diferencia entre Pick y Omit es de intención. Con cuatro campos, ambos pueden dar el mismo tipo resultante. Pero si Heroe ganara cinco campos nuevos, Pick<Heroe, "nombre" | "rol"> los ignoraría todos, mientras que Omit<Heroe, "partidas" | "victorias"> los incluiría todos.

Record<K, V>#

Record<K, V> describe un objeto-diccionario: todas sus claves son del tipo K y todos sus valores son del tipo V. Es la alternativa legible al patrón { [clave: string]: Valor }.

typescript
// Un objeto cuyas claves son strings (el rol) y cuyos valores son listas de heroes.
function agruparPorRol(lista: Heroe[]): Record<string, Heroe[]> {
  // Acumulador vacío: TypeScript sabe que sus claves son strings y sus valores Heroe[].
  const grupos: Record<string, Heroe[]> = {};
  // Recorremos cada heroe de la lista.
  for (const h of lista) {
    // Si la clave aún no existe, la inicializamos con un array vacío.
    if (!grupos[h.rol]) { grupos[h.rol] = []; }
    // Añadimos el heroe al grupo correspondiente a su rol.
    grupos[h.rol].push(h);
  }
  return grupos;
}

Cuando las claves son conocidas de antemano, puedes acotarlas con un union literal: Record<"Daño" | "Tanque" | "Apoyo", Heroe[]>. Así TypeScript avisa si intentas acceder con una clave que no existe.

Readonly<T>#

Readonly<T> marca todos los campos del tipo como de solo lectura. Una vez creado el objeto, TypeScript impide cualquier reasignación. A diferencia del modificador readonly campo a campo en la interface —que afecta a todos los usos del tipo—, Readonly<T> lo aplicas en un punto concreto: el mismo tipo sigue siendo mutable en otros sitios.

typescript
// tracer no debería modificarse: Readonly<Heroe> lo garantiza.
const tracer: Readonly<Heroe> = {
  nombre: "Tracer",
  rol: "Daño",
  partidas: 120,
  victorias: 78,
};

// TypeScript impide la reasignación antes de ejecutar.
// error: no se puede asignar a una propiedad readonly
// tracer.partidas = 999;

ReturnType<typeof fn>#

Antes de ver el utility, una aclaración importante: el typeof que ves aquí no es el mismo typeof que usaste en narrowing. En narrowing, typeof se ejecuta en tiempo de ejecución y devuelve un string como "string" o "number". Aquí, dentro de un tipo (ReturnType<typeof fn>), typeof es un operador de tipo: le dice a TypeScript “dame el tipo estático de esta función o valor”, y eso ocurre en tiempo de compilación, no en tiempo de ejecución. Es la forma de pasarle una función —no su resultado— a un utility type.

ReturnType<typeof fn> extrae el tipo de retorno de una función. No la llama: actúa sobre el tipo de la función en tiempo de compilación. Es útil cuando el tipo de retorno es complejo o cuando quieres que el tipo derivado se actualice solo si la función cambia.

typescript
function calcularResumen(lista: Heroe[]) {
  return { cantidad: lista.length, totalPartidas: 0, winrate: 0 };
}

// ResumenEquipo se deriva del retorno de calcularResumen.
// Si mañana añades un campo al objeto, ResumenEquipo lo recoge sin tocar nada.
type ResumenEquipo = ReturnType<typeof calcularResumen>;

El patrón ReturnType<typeof fn> cierra el círculo de la única fuente de verdad: los tipos se derivan de la lógica real, no al revés.

Comprueba lo que sabes#

Pregunta 1 de 5

¿Qué hace `Partial<Heroe>`?

Tu turno#

Tienes tres funciones con tipos incorrectos o demasiado amplios. Tu tarea: reemplaza cada tipo por el utility type correcto derivado de Heroe, sin duplicar la forma del tipo a mano. El panel “Problemas” y los TODO te guían. Cuando el panel quede vacío y al pulsar “Ejecutar” veas los resultados en la consola, has terminado.

Ejercicio · en esta página

Tipos derivados para el Team Builder

Tienes tres funciones con tipos incorrectos o demasiado amplios. Tu tarea: reemplaza cada tipo por el utility type correcto derivado de Heroe. El panel "Problemas" y los TODO te guían.

Paso 1: Que funcione

  • El panel "Problemas" queda vacío: no hay errores de tipo.
  • actualizarHeroe acepta `Partial<Heroe>` como segundo argumento.
  • tarjetaHeroe devuelve `Pick<Heroe, "nombre" | "rol">`.
  • agruparPorRol devuelve `Record<string, Heroe[]>`.
Ver soluciones
// SOLUCIÓN OK — Los utility types sustituyen a "any": el panel queda vacío.
// El punto débil: el tipo de retorno de agruparPorRol usa string como clave,
// que es demasiado amplio. Funciona, pero hay un utility más preciso.

interface Heroe {
  readonly nombre: string;
  readonly rol: string;
  partidas: number;
  victorias: number;
}

const equipo: Heroe[] = [
  { nombre: "Tracer",    rol: "Daño",   partidas: 120, victorias: 78  },
  { nombre: "Mercy",     rol: "Apoyo",  partidas: 200, victorias: 155 },
  { nombre: "Reinhardt", rol: "Tanque", partidas: 95,  victorias: 61  },
  { nombre: "Ana",       rol: "Apoyo",  partidas: 140, victorias: 98  },
];

// Partial<Heroe>: todos los campos de Heroe pasan a ser opcionales.
// Perfecto para una actualización parcial: solo mandas los campos que cambian.
function actualizarHeroe(base: Heroe, cambios: Partial<Heroe>): Heroe {
  return { ...base, ...cambios };
}

// Pick<Heroe, "nombre" | "rol">: solo estos dos campos del tipo original.
// La función promete devolver exactamente eso, ni más ni menos.
function tarjetaHeroe(h: Heroe): Pick<Heroe, "nombre" | "rol"> {
  return { nombre: h.nombre, rol: h.rol };
}

// Record<string, Heroe[]>: un objeto con claves string y valores Heroe[].
// Clave amplia (string): válida, aunque no acota cuáles son los roles posibles.
function agruparPorRol(lista: Heroe[]): Record<string, Heroe[]> {
  const grupos: Record<string, Heroe[]> = {};
  for (const h of lista) {
    if (!grupos[h.rol]) {
      grupos[h.rol] = [];
    }
    grupos[h.rol].push(h);
  }
  return grupos;
}

const tracer = equipo[0];

const tracerActualizado = actualizarHeroe(tracer, { partidas: 125 });
console.log("Actualizado: " + tracerActualizado.nombre + "" + tracerActualizado.partidas + " partidas");

const tarjeta = tarjetaHeroe(tracer);
console.log("Tarjeta: " + tarjeta.nombre + " (" + tarjeta.rol + ")");

const porRol = agruparPorRol(equipo);
const roles = Object.keys(porRol);
for (const rol of roles) {
  const nombres = porRol[rol].map(function(h) { return h.nombre; }).join(", ");
  console.log(rol + ": " + nombres);
}

Por qué este nivel

  • Sustituyes los tres `any` por `Partial<Heroe>`, `Pick<Heroe, "nombre" | "rol">` y `Record<string, Heroe[]>`. El panel queda vacío.
  • La clave de Record es `string`: válida, pero no acota los roles posibles. TypeScript no puede avisar si usas una clave que no existe.
  • Es la solución directa de los TODO: correcta, pero hay margen para ser más preciso.