Inicio / Artículos / Herramientas avanzadas de TypeScript: extends, infer, keyof Maps y as Remaps

Herramientas avanzadas de TypeScript: extends, infer, keyof Maps y as Remaps

Cree tipos de nivel de biblioteca combinando condicionales, inferencia, bucles mapeados y reasignación de claves: la misma herramienta que se utiliza en Zod y tRPC.

1192 palabras

Después de meses trabajando con TypeScript, las funciones integradas como Partial, Pick y Omit eventualmente dejan de satisfacer las necesidades reales de las bibliotecas: extraer el tipo de retorno de una función, transformar cada clave o crear tipos que se adapten a otros tipos. Cuatro características se combinan para lograr ese nivel de funcionalidad: extends condicional, infer, bucles mapeados sobre keyof y reasignación de claves con as. Por separado son fáciles de entender; juntas permiten implementar patrones utilizados en Zod, tRPC y React Router.

1. Tipos condicionales con extends

En el contexto de los tipos, extends funciona como una instrucción if:

type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>;      // false

Significado: si T es asignable a string, se devuelve true; de lo contrario, false.

Las condiciones se vuelven más estrictas cuando son distributivas. Una unión T se verifica miembro por miembro:

type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// Result = string[] | number[]   (not (string | number)[])

Por eso el filtrado de uniones es sencillo:

type ExcludeString<T> = T extends string ? never : T;
type NoStrings = ExcludeString<string | number | boolean>;
// NoStrings = number | boolean

never no desaparece de las uniones; esa es la misma lógica detrás de la función integrada Exclude<T, U>.

2. Extracción de formas con infer

infer solo aparece dentro de una cláusula extends. Declara un marcador que TypeScript rellena con la forma correspondiente. Es un método clásico de extracción del tipo de retorno:

type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
  return { id: 1, name: "Oussama" };
}type User = MyReturnType<typeof getUser>;
// User = { id: number; name: string }

Pasos: comparar typeof getUser con (...args: any[]) => infer R, asociar R al valor de retorno real y finalmente obtener R.

infer también funciona en otras posiciones:

// Extract the element type of an array
type ElementOf<T> = T extends (infer U)[] ? U : never;
type Item = ElementOf<string[]>; // string
// Extract the resolved type of a Promise
type Awaited2<T> = T extends Promise<infer U> ? U : T;
type Data = Awaited2<Promise<{ status: number }>>;
// Data = { status: number }// Extract the first argument of a function
type FirstArg<T> = T extends (arg: infer A, ...rest: any[]) => any ? A : never;
type Arg = FirstArg<(id: number, name: string) => void>; // number

La recursión despliega las promesas anidadas:

type DeepAwaited<T> = T extends Promise<infer U> ? DeepAwaited<U> : T;
type Flat = DeepAwaited<Promise<Promise<Promise<string>>>>;
// Flat = string

3. Bucles con tipos mapeados keyof

keyof genera una unión de claves:

interface User {
  id: number;
  name: string;
  email: string;
}
type UserKeys = keyof User; // "id" | "name" | "email"

Los tipos mapeados iteran sobre esa unión como un for...in a nivel de tipo:

type Readonly2<T> = {
  [K in keyof T]: T[K];
};

Agregue modificadores para copias opcionales o de solo lectura:

// Make every property optional
type Optional<T> = {
  [K in keyof T]?: T[K];
};
// Make every property readonly
type ReadonlyAll<T> = {
  readonly [K in keyof T]: T[K];
};// Remove readonly / optional with a minus modifier
type Mutable<T> = {
  -readonly [K in keyof T]-?: T[K];
};

Transforme valores durante el bucle:

type Stringify<T> = {
  [K in keyof T]: string;
};
type StringifiedUser = Stringify<User>;
// { id: string; name: string; email: string }

Combínelo con condicionales:

type NullableStrings<T> = {
  [K in keyof T]: T[K] extends string ? T[K] | null : T[K];
};
type Result2 = NullableStrings<User>;
// { id: number; name: string | null; email: string | null }

4. Reasignación de claves con as

Dentro de un tipo mapeado, as calcula un nuevo nombre de clave para cada propiedad:

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<User>;
/*
{
  getId: () => number;
  getName: () => string;
  getEmail: () => string;
}
*/

[K in keyof T] realiza la iteración; el literal de plantilla crea claves al estilo getName; T[K] se convierte en el tipo de retorno del getter.

Use as never para eliminar claves durante el bucle:

type OmitByType<T, U> = {
  [K in keyof T as T[K] extends U ? never : K]: T[K];
};
interface Mixed {
  id: number;
  name: string;
  active: boolean;
}type OnlyNonBoolean = OmitByType<Mixed, boolean>;
// { id: number; name: string }

Ese extends dentro del patrón as es la forma en que se construyen las variantes Pick/Omit basadas en valores.

Todos los cuatro juntos

Una combinación realista: validadores para campos de datos, omitiendo propiedades de función:

type Validators<T> = {
  [K in keyof T as T[K] extends Function ? never : `validate${Capitalize<string & K>}`]:
    (value: T[K]) => value is T[K];
};
interface ApiResponse {
  id: number;
  email: string;
  active: boolean;
  refresh: () => void;
}type ResponseValidators = Validators<ApiResponse>;
/*
{
  validateId: (value: number) => value is number;
  validateEmail: (value: string) => value is string;
  validateActive: (value: boolean) => value is boolean;
}
*/

Análisis: keyof proporciona las claves; el bucle mapeado visita cada una de ellas; as renombra o elimina mediante never cuando T[K] extends Function; extends decide si conservar o eliminar; infer aparecería al extraer tipos de argumentos anidados.

Referencia rápida

Característica Rol Dónde
extends Condición a nivel de tipo Comparando tipos
infer Capturar la parte coincidente Solo dentro de extends
[K in keyof T] Claves del bucle Tipos mapeados as Cambiar nombre/eliminar claves Dentro de la cláusula as mapeada

Al hacer clic en estos elementos, las formas complejas dejan de forzar el uso de any. Vuelve a crear Pick, Omit, Record y ReturnType desde cero: todas las funcionalidades anteriores estarán disponibles, y la herramienta a nivel de tipos se vuelve práctica en lugar de mística. Los autores de bibliotecas dependen de estos mismos cuatro mecanismos; leer sus archivos .d.ts se vuelve mucho más fácil después de pasar un fin de semana reconstruyendo las utilidades a mano.

Ruta de práctica

Vuelve a construir Exclude con condicionales distributivos, ReturnType con infer, Partial/Readonly con modificadores mapeados, y un OmitByType personalizado con as never. Esos cuatro ejercicios utilizan todas las características de esta guía. Después de eso, leer los archivos .d.ts de la biblioteca deja de parecerse al código golf y comienza a sentirse como TypeScript normal, pero solo en el nivel de tipos.

Cuando un error de tipo se convierte en una serie interminable de condicionales, aplica el método de bisección: reemplaza temporalmente un tipo mapeado por un tipo de objeto concreto, o sustituye infer R por una anotación explícita, hasta que la rama problemática sea evidente. La depuración a nivel de tipos sigue siendo depuración; se aplica la misma estrategia de dividir y vencer.

Preferir alias con nombre en lugar de líneas muy largas para que los lectores futuros puedan comprender la intención. Exportar solo las herramientas que la aplicación realmente reutiliza; dejar las utilidades especulativas en un entorno aislado hasta que aparezca otro lugar donde se las necesite. De esta manera, la superficie de tipos será tan intencional como la API en tiempo de ejecución.