Type inference avanzado en TypeScript: infer, distribución y tuplas variádicas

TypeScript tiene fama de ser verboso, pero cuando le sacas partido al sistema de inferencia avanzado ocurre algo curioso: escribes menos tipos, no más. La clave está en entender cómo funciona infer, cómo se distribuyen los conditional types sobre uniones y qué posibilidades abren las tuplas variádicas. Este artículo cubre todo eso, con ejemplos concretos y sin rodeos.

infer dentro de conditional types

infer es una palabra clave que solo tiene sentido dentro de un conditional type. Lo que hace es declarar una variable de tipo que TypeScript rellena automáticamente cuando hace pattern matching contra la estructura de T. En cuanto encuentra una coincidencia, esa variable queda disponible en la rama verdadera del condicional.

El ejemplo más clásico es desempaquetar una Promise:

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

Cuando escribes UnwrapPromise<Promise<string>>, TypeScript hace el matching y deduce que U es string, así que el resultado es string. Si le pasas UnwrapPromise<number>, no hay Promise que desempaquetar y devuelve number tal cual.

Puedes usar infer para desempaquetar cualquier cosa: arrays, funciones, objetos con forma conocida. Lo útil es que no tienes que saber de antemano qué tipo hay dentro; dejas que TypeScript lo deduzca por ti.

ReturnType y Parameters explicados con infer

Estas dos utilidades de la librería estándar de TypeScript usan infer internamente, y merece la pena ver cómo están implementadas porque dejan claro el patrón:

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type Parameters<T> = T extends (...args: infer P) => any ? P : never;

En el primer caso, TypeScript hace matching contra cualquier función y extrae el tipo de retorno en R. En el segundo, extrae los parámetros como tupla en P.

La aplicación práctica es evitar duplicar anotaciones. Si tienes una función que ya existe, puedes derivar su tipo de retorno con ReturnType<typeof miFuncion> en lugar de copiarlo a mano. La ventaja es que si cambias la función, el tipo derivado se actualiza solo sin que tengas que tocarlo.

function fetchUser(id: number) {
  return { id, name: 'Ana', role: 'admin' };
}

type User = ReturnType<typeof fetchUser>;
// User = { id: number; name: string; role: string }

Esto también viene bien cuando trabajas con funciones de terceros cuyo tipo de retorno no está exportado explícitamente.

Distribución en conditional types

Cuando el tipo genérico que pasas a un conditional type es una unión, TypeScript aplica el condicional a cada miembro por separado y une los resultados. Se llama distribución y ocurre de forma automática:

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

type Result = ToArray<string | number>;
// Result = string[] | number[]

TypeScript ha evaluado string extends any ? string[] : never y number extends any ? number[] : never por separado, y ha unido los resultados. No has tenido que hacer nada especial.

Ahora bien, a veces la distribución no es lo que quieres. Supón que necesitas que ToArray<string | number> devuelva (string | number)[] en lugar de string[] | number[]. Para eso envuelves T entre corchetes:

type NoDistrib<T> = [T] extends [any] ? T[] : never;

type Result = NoDistrib<string | number>;
// Result = (string | number)[]

Con los corchetes, TypeScript trata a T como un tipo único en lugar de distribuirlo. Es un truco sencillo pero que conviene tener claro para no llevarse sorpresas.

Tuplas variádicas: arrays con longitud y forma conocidas

Las tuplas variádicas, introducidas en TypeScript 4.0, permiten operar sobre la estructura de arrays tipados de forma muy precisa. Con el operador spread ... dentro de un tipo tupla puedes concatenar, extraer o manipular partes de arrays manteniendo la información de tipos completa.

type Concat<A extends any[], B extends any[]> = [...A, ...B];

type Result = Concat<[1, 2], [3, 4]>;
// Result = [1, 2, 3, 4]

Combinado con infer, puedes extraer el primer o el último elemento de una tupla:

type First<T extends any[]> = T extends [infer F, ...any[]] ? F : never;
type Last<T extends any[]>  = T extends [...any[], infer L] ? L : never;

type A = First<[string, number, boolean]>; // string
type B = Last<[string, number, boolean]>;  // boolean

Esto es especialmente útil para tipar funciones que trabajan con pipelines o composición, donde el tipo de entrada de un paso depende del tipo de salida del anterior.

Predicados de tipo inferidos (TypeScript 5.5+)

Antes de TypeScript 5.5, filtrar un array para quitar nulos era un problema habitual: aunque la condición era obvia, el compilador no era capaz de estrecharlo automáticamente.

// Antes de TS 5.5
const nums = [1, null, 2, null].filter(x => x !== null);
// nums: (number | null)[]  — TypeScript no lo estrecha

Con TypeScript 5.5, el compilador infiere el predicado de tipo cuando la condición del callback lo permite sin ambigüedad:

// TS 5.5+
const nums = [1, null, 2, null].filter(x => x !== null);
// nums: number[]  — inferido automáticamente

Cuándo no funciona: si la condición es compleja o ambigua, TypeScript no infiere el predicado. En ese caso sigues necesitando el type predicate manual:

function isNumber(x: number | null): x is number {
  return x !== null;
}
const nums = [1, null, 2, null].filter(isNumber); // number[]

Template literal types avanzados

TypeScript puede operar sobre strings a nivel de tipos, lo que abre posibilidades que a primera vista parecen magia. Con template literal types defines patrones de string que el compilador verifica estáticamente.

type GetterName<T extends string> = `get${Capitalize<T>}`;

type G = GetterName<'nombre'>; // 'getNombre'

Puedes combinar esto con mapped types para generar automáticamente los tipos de todos los getters de un objeto:

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

type PersonaGetters = Getters<{ nombre: string; edad: number }>;
// {
//   getNombre: () => string;
//   getEdad:   () => number;
// }

También puedes restringir qué strings son válidos en función de su prefijo. Por ejemplo, un tipo que solo acepta strings que empiezan por on:

type EventName = `on${string}`;

const handler: EventName = 'onClick';   // OK
const bad: EventName     = 'clickHandler'; // Error

Estos tipos son especialmente útiles para librerías de eventos, validaciones de nombres de campos o cualquier API donde los strings siguen una convención predecible. Puedes ver más sobre el sistema de tipos de TypeScript y sus capacidades en otro artículo del sitio.

Tipos recursivos

TypeScript permite que un tipo alias se referencie a sí mismo, lo que hace posible representar estructuras de datos anidadas de profundidad arbitraria. El caso más típico es JSON:

type JSONValue =
  | string
  | number
  | boolean
  | null
  | JSONValue[]
  | { [key: string]: JSONValue };

Otro uso frecuente es DeepPartial, que hace opcionales todas las propiedades de un objeto de forma recursiva:

type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T;

TypeScript impone un límite interno de profundidad de recursión para evitar bucles infinitos. En la mayoría de casos no te vas a topar con él, pero si trabajas con estructuras muy profundas puede aparecer un error de "type instantiation is excessively deep". Una solución habitual es usar un parámetro de profundidad que actúe de freno:

type DeepPartialWithDepth<T, D extends number = 5> = {
  0: T;
  1: T extends object ? { [K in keyof T]?: DeepPartialWithDepth<T[K]> } : T;
}[D extends 0 ? 0 : 1];

No es la solución más elegante, pero funciona cuando el compilador empieza a quejarse de recursión excesiva. Para entender cómo llegamos aquí desde de JavaScript a TypeScript: inferencia automática, conviene tener clara la evolución del lenguaje.

NoInfer<T> (TypeScript 5.4+)

TypeScript 5.4 introdujo NoInfer<T>, un tipo de utilidad que le dice al compilador que ignore ese argumento concreto a la hora de inferir el tipo genérico. El caso de uso más claro es funciones donde un parámetro debe fijar el tipo y los demás solo tienen que ser compatibles con él, sin influir en la deducción.

Un ejemplo clásico es clamp:

function clamp<T extends number>(
  value: T,
  min: NoInfer<T>,
  max: NoInfer<T>
): T {
  return Math.min(Math.max(value, min), max) as T;
}

Sin NoInfer, TypeScript intenta inferir T a partir de los tres argumentos a la vez, lo que puede dar lugar a tipos más amplios de lo esperado cuando los valores son literales. Con NoInfer, solo value participa en la inferencia; min y max se limitan a verificar que son compatibles con el tipo ya deducido.

Es un detalle fino, pero en APIs con múltiples parámetros genéricos puede marcar la diferencia entre inferencia precisa y tipos degenerados que te obligan a anotar todo a mano.

Cuándo usar todo esto

El sistema de tipos avanzado de TypeScript brilla sobre todo cuando construyes librerías, utilidades compartidas o APIs internas que otros van a consumir. En ese contexto, derivar tipos automáticamente en lugar de duplicarlos reduce los errores de mantenimiento y hace que los cambios se propaguen solos.

Para código de aplicación más directo, a menudo basta con tipos simples y anotaciones explícitas. No hay que meter infer y conditional types en todos lados; úsalos cuando el problema real lo pide.

Imagen: Pexels / cottonbro studio

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP