Mapped types y conditional types en TypeScript

1. Resumen general

En proyectos TypeScript, frecuentemente necesitas escribir muchos tipos similares: Order, luego ReadonlyOrder, OrderUpdate, OrderGetters… Escribir manualmente cada tipo es tedioso y fácil de cometer errores cuando Order cambia.

Mapped types y conditional types resuelven exactamente este problema. Permiten generar nuevos tipos a partir de tipos existentes. Después de leer este artículo, podrás escribir por tu cuenta tipos utilitarios como Partial, PickByType, UnwrapPromise. También sabrás cómo testearlos y cuándo parar.

2. Preparación

Necesitas TypeScript 5.x con strict habilitado. El artículo usa Vitest para testing de tipos:

npm install -D typescript vitest
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noEmit": true
  }
}

strict: true también habilita strictNullChecks. Algunos resultados en el artículo, por ejemplo si undefined coincide con string, dependen de esta opción.

Los ejemplos usan el siguiente tipo compartido:

export interface Order {
  id: string;
  customerId: string;
  total: number;
  paid: boolean;
  note?: string;
}

3. Mapped types

3.1. Sintaxis básica

Un mapped type itera sobre un conjunto de keys y crea una property para cada key:

type ReadonlyOrder = {
  readonly [K in keyof Order]: Order[K];
};

const order: ReadonlyOrder = {
  id: "o-1",
  customerId: "c-1",
  total: 250_000,
  paid: false,
};

order.paid = true; // Error de compilación

Explicación:

  • keyof Order es la unión "id" | "customerId" | "total" | "paid" | "note".
  • [K in ...] itera sobre cada key en esa unión.
  • Order[K] es un indexed access type. Obtiene el tipo de la property K.
  • Los mapped types de forma [K in keyof T] se llaman homomorphic. Este tipo preserva los modificadores originales, por lo que note sigue siendo opcional.

La línea de asignación final produce un error:

error TS2540: Cannot assign to 'paid' because it is a read-only property.

El ejemplo anterior está codificado a Order. Para reutilizarlo con cualquier tipo, añades un parámetro genérico:

type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
type MyPartial<T> = { [K in keyof T]?: T[K] };

type OrderUpdate = MyPartial<Order>;
// { id?: string; customerId?: string; total?: number; paid?: boolean; note?: string }

Así es exactamente como la librería estándar define Readonly<T> y Partial<T>.

3.2. Añadir y quitar modificadores

Puedes poner + o - delante de readonly y ?. El signo + es el predeterminado, así que suele omitirse. El signo - se usa para remover un modificador:

type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Concrete<T> = { [K in keyof T]-?: T[K] };

type CompleteOrder = Concrete<Order>;

const draft: CompleteOrder = {
  id: "o-2",
  customerId: "c-9",
  total: 99_000,
  paid: true,
  note: "Entregar en horario de oficina",
};

Con Concrete<Order>, la property note se vuelve obligatoria. Su tipo es string, no string | undefined. La librería estándar proporciona Required<T> que hace exactamente esto.

3.3. Renombrar keys con as

Desde TypeScript 4.1, puedes renombrar keys usando la cláusula as. Esta técnica se usa frecuentemente con template literal types:

interface Customer {
  id: string;
  name: string;
  email: string;
}

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type CustomerGetters = Getters<Customer>;
// {
//   getId: () => string;
//   getName: () => string;
//   getEmail: () => string;
// }

La parte string & K es obligatoria. keyof T puede contener number y symbol, mientras que Capitalize solo acepta string. La intersección de tipos elimina las keys que no sean strings.

También puedes usar as para filtrar keys. Si la expresión retorna never, esa key se excluye del resultado:

type WithoutId<T> = {
  [K in keyof T as Exclude<K, "id">]: T[K];
};

type NewOrder = WithoutId<Order>;
// { customerId: string; total: number; paid: boolean; note?: string }

Exclude aquí es un conditional type. Ese es el tema de la siguiente sección.

4. Conditional types

4.1. Sintaxis extends ? :

Un conditional type funciona como un operador ternario, pero a nivel de tipos:

type IsString<T> = T extends string ? true : false;

type A = IsString<"hello">; // true
type B = IsString<42>;      // false

T extends string significa “¿se puede asignar T a string?”. No es herencia de clases.

4.2. Distributividad

Cuando T es un parámetro genérico desnudo y pasas una unión, TypeScript aplica la condición a cada miembro de la unión:

type ToArray<T> = T extends unknown ? T[] : never;
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;

type C = ToArray<string | number>;        // string[] | number[]
type D = ToArrayNonDist<string | number>; // (string | number)[]

Los dos resultados son completamente diferentes. Envolver T en una tupla [T] desactiva la distributividad.

La distributividad es la base de Exclude y Extract:

type MyExclude<T, U> = T extends U ? never : T;

type Status = "pending" | "paid" | "cancelled";
type ActiveStatus = MyExclude<Status, "cancelled">; // "pending" | "paid"

TypeScript examina cada miembro. "cancelled" se convierte en never, y never desaparece automáticamente de la unión.

Nota: Un conditional type distributivo que recibe never retorna never, porque never es una unión vacía. Por ejemplo, ToArray<never> es never, no never[]. Si necesitas detectar never, usa la forma envuelta en tupla: [T] extends [never] ? ... : ....

4.3. Extraer tipos con infer

infer te permite “capturar” una parte de un tipo dentro de la rama de la condición:

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;

async function fetchOrder(id: string): Promise<Order> {
  return { id, customerId: "c-1", total: 0, paid: false };
}

type FetchResult = ReturnType<typeof fetchOrder>; // Promise<Order>
type FetchedOrder = UnwrapPromise<FetchResult>;   // Order
type Item = ElementOf<Order[]>;                    // Order

Significado de cada parte:

  • Promise<infer U> declara una variable de tipo U y deja que TypeScript deduzca su valor.
  • Si coincide, la rama true puede usar U. Si no coincide, UnwrapPromise retorna T sin cambios.
  • ReturnType y Parameters en la librería estándar también están escritos con infer.

En la práctica, deberías usar Awaited<T> que ya existe. Maneja Promises anidadas como Promise<Promise<Order>>.

5. Combinando ambas técnicas

5.1. Seleccionar properties por tipo de valor

Colocando un conditional type dentro de la cláusula as, puedes filtrar keys según el tipo del valor:

type PickByType<T, V> = {
  [K in keyof T as T[K] extends V ? K : never]: T[K];
};

type OrderNumbers = PickByType<Order, number>; // { total: number }
type OrderStrings = PickByType<Order, string>; // { id: string; customerId: string }

Nota que note no aparece en OrderStrings. El tipo de Order["note"] es string | undefined, y este tipo no se puede asignar a string cuando strictNullChecks está habilitado. Aquí T[K] no es un parámetro desnudo, así que la condición no es distributiva. La unión se examina como un bloque.

5.2. Handlers para eventos con tipos estrictos

Este es un ejemplo cercano al código real. Tienes una tabla de eventos para órdenes y quieres generar tipos para los handlers:

type OrderEvents = {
  created: { orderId: string; total: number };
  paid: { orderId: string; paidAt: Date };
  cancelled: { orderId: string; reason: string };
};

type EventHandlers<E> = {
  [K in keyof E as `on${Capitalize<string & K>}`]: (payload: E[K]) => void;
};

const handlers: EventHandlers<OrderEvents> = {
  onCreated: (p) => console.log(`Orden creada ${p.orderId}: ${p.total}`),
  onPaid: (p) => console.log(`Pagada el ${p.paidAt.toISOString()}`),
  onCancelled: (p) => console.log(`Cancelada por: ${p.reason}`),
};

handlers.onCreated({ orderId: "o-1", total: 250_000 });
Orden creada o-1: 250000

El parámetro p en cada handler se deduce correctamente, así que no necesitas declararlo de nuevo. Cuando añadas un evento refunded a OrderEvents, TypeScript reportará un error inmediatamente porque handlers carece de onRefunded.

5.3. Implementar runtime para tipos generados

Los mapped types solo existen en tiempo de compilación. Para tener un objeto real, aún necesitas escribir la lógica:

export function toGetters<T extends object>(obj: T): Getters<T> {
  const result: Record<string, () => unknown> = {};
  for (const key of Object.keys(obj) as (keyof T & string)[]) {
    const name = `get${key.charAt(0).toUpperCase()}${key.slice(1)}`;
    result[name] = () => obj[key];
  }
  return result as unknown as Getters<T>;
}

¿Por qué necesitamos casting? TypeScript no puede probar que el bucle runtime crea exactamente la forma Getters<T>. El casting as unknown as es donde confirmas eso. Por lo tanto, este código debe tener tests.

6. Testing de tipos con Vitest

Vitest tiene expectTypeOf para verificar tipos, junto con assertions normales de runtime:

import { describe, it, expect, expectTypeOf } from "vitest";
import type { Order } from "./order";
import { toGetters } from "./getters";

describe("utility types", () => {
  it("PickByType mantiene solo properties con tipos coincidentes", () => {
    expectTypeOf<PickByType<Order, number>>().toEqualTypeOf<{ total: number }>();
    expectTypeOf<PickByType<Order, string>>()
      .toEqualTypeOf<{ id: string; customerId: string }>();
  });

  it("UnwrapPromise desenvuelve Promise un nivel", () => {
    expectTypeOf<UnwrapPromise<Promise<Order>>>().toEqualTypeOf<Order>();
    expectTypeOf<UnwrapPromise<number>>().toEqualTypeOf<number>();
  });

  it("toGetters retorna getters con valores correctos", () => {
    const getters = toGetters({ id: "c-1", name: "Ana", email: "ana@example.com" });

    expectTypeOf(getters.getName).returns.toEqualTypeOf<string>();
    expect(getters.getName()).toBe("Ana");
    expect(getters.getEmail()).toBe("ana@example.com");

    // @ts-expect-error el getter no existe debe reportar error de compilación
    getters.getPhone;
  });
});

Ejecuta los tests con verificación de tipos:

npx vitest run --typecheck

Ten en cuenta estos puntos:

  • En runtime, expectTypeOf no hace nada. La verificación real sucede cuando el compilador analiza el archivo. Por eso necesitas el flag --typecheck, o ejecutar npx tsc --noEmit en CI.
  • // @ts-expect-error se usa para tests negativos. Si la línea siguiente no tiene error, por ejemplo cuando alguien hace el tipo más permisivo, este directive causará un error de compilación.
  • El test de runtime para toGetters te ayuda a detectar cuando la lógica runtime se desvía del tipo comprometido.

7. Errores comunes y cuándo no usar esto

Olvidar string & K al usar template literals. Escribir `get${Capitalize<K>}` generará un error, porque K puede ser number | symbol.

No prestar atención a la distributividad. Escribes IsString<string | number> esperando false, pero obtienes boolean, es decir true | false. Si quieres examinar toda la unión como un bloque, envuélvela en tupla.

Confundirse con extends y any. any hace que la condición retorne la unión de ambas ramas. Por ejemplo, IsString<any> es boolean.

Perder modificadores cuando mapeas keys no homomorphic. Un mapped type como [K in "id" | "total"]: Order[K] no preserva readonly o ? de Order. Solo la forma [K in keyof T] preserva modificadores. Si necesitas seleccionar algunas keys mientras preservas modificadores, usa Pick.

Recursión demasiado profunda. Tipos como DeepPartial<T> que aplican recursión a objetos anidados pueden causar Type instantiation is excessively deep and possibly infinite. También hacen que el editor se ralentice notablemente. Además, Date, Map y arrays también son object, así que la condición T[K] extends object frecuentemente los captura erróneamente.

Nota: No uses mapped types y conditional types solo para “parecer inteligente”. Si un tipo se usa en un solo lugar y cambia raramente, una interface escrita manualmente será mucho más fácil de leer. Los mensajes de error de tipos anidados 3–4 niveles profundos son muy difíciles de entender para los colegas. Prefiere tipos utilitarios existentes (Partial, Pick, Omit, Record, Exclude, ReturnType, Awaited) antes de escribir los tuyos propios.

Creer que los tipos protegen runtime. Los mapped types no validan datos de APIs. Si JSON retorna sin un campo, Getters<Customer> sigue siendo “correcto” en compilación pero incorrecto en runtime. Para datos de fuentes externas, necesitas una validación en runtime adicional.

8. Conclusión

Este artículo ha introducido dos herramientas centrales para generar tipos en TypeScript:

  • Mapped types, con [K in keyof T], permiten iterar keys, añadir o remover readonly/?, renombrar y filtrar keys con as.
  • Conditional types, con T extends U ? X : Y, permiten ramificación a nivel de tipos. Se distribuyen sobre uniones y extraen tipos con infer.

Cuando combinas ambas técnicas, escribes tipos como PickByType o EventHandlers. Estos tipos se actualizan automáticamente cuando el tipo base cambia. Siempre acompaña con tests usando expectTypeOf y @ts-expect-error. Ejecuta --typecheck en CI. Y mantén estos tipos suficientemente simples para que otros puedan entenderlos.

typescriptmapped typesconditional typesgenericstype system