Los genéricos permiten relacionar tipos. Esa frase es más útil que “permiten reutilizar código”: un buen genérico conserva información entre la entrada y la salida, restringe combinaciones inválidas o deriva un tipo nuevo a partir de otro.

El objetivo no es escribir el tipo más ingenioso. Es conseguir que una API sea difícil de usar mal y fácil de entender desde el editor.

Primero: identifica la relación

Compara estas dos funciones:

function firstUnsafe(items: unknown[]): unknown {
  return items[0];
}

function first<T>(items: T[]): T | undefined {
  return items[0];
}

T expresa que el elemento devuelto tiene el mismo tipo que los elementos recibidos. Si llamas first(['a', 'b']), TypeScript conserva string; no necesitas indicarlo manualmente.

flowchart LR
    I["Entrada: T[]"] --> G["Relación genérica"]
    G --> O["Salida: T | undefined"]
    C["El compilador infiere T"] --> G

Antes de agregar un parámetro genérico, completa esta frase: “el tipo de ___ depende del tipo de ___”. Si no puedes nombrar la relación, quizá no necesitas un genérico.

Restricciones con extends

Un genérico sin restricciones acepta cualquier cosa. Si la implementación necesita una capacidad concreta, declárala:

type WithId = { id: string };

function indexById<T extends WithId>(items: T[]): Map<string, T> {
  return new Map(items.map((item) => [item.id, item]));
}

const users = indexById([
  { id: 'u1', name: 'Ana', role: 'admin' },
]);

// Map<string, { id: string; name: string; role: string }>

La restricción garantiza id, pero conserva las demás propiedades del tipo inferido. Usar WithId[] directamente perdería esa precisión en el retorno.

Relaciona claves y valores con keyof

Este patrón aparece en selectores, formularios y actualizaciones parciales:

function getProperty<T, K extends keyof T>(object: T, key: K): T[K] {
  return object[key];
}

const article = {
  title: 'Genéricos útiles',
  views: 120,
  published: true,
};

const title = getProperty(article, 'title'); // string
const views = getProperty(article, 'views'); // number
// getProperty(article, 'author');           // error

Aquí existen dos relaciones:

  1. K solo puede ser una clave de T.
  2. El resultado es el tipo específico de T[K].

Puedes aplicar la misma idea a un parche seguro:

function updateField<T, K extends keyof T>(
  object: T,
  key: K,
  value: T[K],
): T {
  return { ...object, [key]: value };
}

updateField(article, 'views', 121);   // válido
// updateField(article, 'views', '121'); // error

Tipos condicionales: decisiones en el sistema de tipos

La forma general es:

type Result<T> = T extends Condition ? WhenTrue : WhenFalse;

Un ejemplo práctico es extraer el valor resuelto de una promesa:

type AwaitedValue<T> = T extends Promise<infer Value>
  ? Value
  : T;

type A = AwaitedValue<Promise<string>>; // string
type B = AwaitedValue<number>;          // number

infer Value crea una variable de tipo a partir de la parte que coincide con el patrón. No inspecciona valores en runtime; opera exclusivamente durante la comprobación de tipos.

Un resultado que obliga a manejar errores

Las uniones discriminadas hacen explícitos ambos caminos:

type Result<TData, TError> =
  | { ok: true; data: TData }
  | { ok: false; error: TError };

type User = {
  id: string;
  name: string;
};

type FindUserError =
  | { code: 'NOT_FOUND' }
  | { code: 'NETWORK_ERROR'; retryable: boolean };

async function findUser(
  id: string,
): Promise<Result<User, FindUserError>> {
  // implementación
}

Al consumirlo, ok estrecha el tipo:

const result = await findUser('u1');

if (!result.ok) {
  if (result.error.code === 'NETWORK_ERROR' && result.error.retryable) {
    // ofrecer reintento
  }
  return;
}

console.log(result.data.name);

Ahora podemos derivar los tipos de éxito y error:

type SuccessOf<T> =
  T extends Result<infer Data, unknown> ? Data : never;

type ErrorOf<T> =
  T extends Result<unknown, infer Failure> ? Failure : never;

type UserData = SuccessOf<Result<User, FindUserError>>; // User
type UserFailure = ErrorOf<Result<User, FindUserError>>; // FindUserError

Mapped types: transforma cada propiedad

Un mapped type itera sobre claves conocidas:

type FieldState<T> = {
  [K in keyof T]: {
    value: T[K];
    touched: boolean;
    error?: string;
  };
};

type Profile = {
  name: string;
  age: number;
};

type ProfileForm = FieldState<Profile>;

ProfileForm['age']['value'] sigue siendo number. La transformación añade metadatos sin perder la relación con cada campo original.

También puedes cambiar modificadores:

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

Los prefijos - eliminan readonly y el carácter opcional ?.

Remapeo de claves con as

Puedes construir una API derivada sin repetir nombres:

type Setters<T> = {
  [K in keyof T as `set${Capitalize<string & K>}`]:
    (value: T[K]) => void;
};

type Settings = {
  theme: 'light' | 'dark';
  notifications: boolean;
};

type SettingsSetters = Setters<Settings>;
// {
//   setTheme(value: 'light' | 'dark'): void;
//   setNotifications(value: boolean): void;
// }

Este patrón es útil en generación de clientes o adaptadores, pero puede ocultar demasiado si el equipo no puede descubrir fácilmente el tipo resultante.

La trampa de la distribución

Los tipos condicionales se distribuyen sobre uniones cuando el parámetro aparece “desnudo” a la izquierda de extends:

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

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

Si quieres evaluar la unión completa, envuelve ambos lados en tuplas:

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

type Together = ToArrayNonDistributed<string | number>;
// (string | number)[]
flowchart TD
    U["string | number"] --> Q{"¿T está desnudo?"}
    Q -->|"Sí"| D["string[] | number[]"]
    Q -->|"No: [T]"| N["(string | number)[]"]

Cuando un conditional type produce un resultado inesperado, verifica primero si está distribuyendo sobre una unión.

Diseña una API tipada paso a paso

Supongamos un cliente de eventos donde cada evento tiene un payload diferente:

type EventMap = {
  'user.created': { userId: string; email: string };
  'article.published': { articleId: string; slug: string };
  'subscription.cancelled': { subscriptionId: string; reason?: string };
};

class EventBus<Events extends Record<string, unknown>> {
  private handlers: {
    [K in keyof Events]?: Array<(payload: Events[K]) => void>;
  } = {};

  on<K extends keyof Events>(
    event: K,
    handler: (payload: Events[K]) => void,
  ): void {
    const handlers = this.handlers[event] ?? [];
    handlers.push(handler);
    this.handlers[event] = handlers;
  }

  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    this.handlers[event]?.forEach((handler) => handler(payload));
  }
}

const events = new EventBus<EventMap>();

events.emit('article.published', {
  articleId: 'a1',
  slug: 'genericos-typescript',
});

El nombre del evento determina el payload permitido. Esa es una relación genérica valiosa: elimina combinaciones inválidas sin pedir anotaciones repetidas al consumidor.

Cuándo un genérico empeora el código

Señales de alerta:

  • Solo se usa una vez y no relaciona entrada con salida.
  • Obliga a pasar parámetros de tipo que el compilador podría inferir.
  • Devuelve un assertion como as T sin que runtime garantice ese tipo.
  • Produce errores del compilador imposibles de explicar.
  • Existe para evitar escribir dos tipos claros y diferentes.
  • Modela todas las posibilidades teóricas, no las que el producto admite.

Este ejemplo promete demasiado:

function parse<T>(value: string): T {
  return JSON.parse(value) as T;
}

El caller elige T, pero nada valida que el JSON coincida. Es más honesto retornar unknown y validar:

function parse(value: string): unknown {
  return JSON.parse(value);
}

Orden recomendado para resolver un tipo complejo

  1. Escribe dos o tres ejemplos concretos de entrada y salida.
  2. Identifica qué información debe conservarse.
  3. Empieza con T y una restricción mínima.
  4. Agrega keyof, indexed access o conditional types solo si resuelven un caso real.
  5. Crea pruebas de tipos para casos válidos e inválidos.
  6. Nombra los tipos intermedios; no concentres todo en una sola línea.

Checklist de calidad

  • El nombre del parámetro comunica su papel: TData, TError, TKey.
  • El compilador puede inferirlo en el uso habitual.
  • La restricción representa la capacidad mínima necesaria.
  • No hay assertions que prometan algo no validado.
  • Los errores de tipo ayudan al consumidor a corregirse.
  • El tipo reduce estados inválidos o repetición significativa.
  • Una persona del equipo puede explicarlo sin ejecutar TypeScript mentalmente.

Los genéricos avanzados aportan valor cuando hacen visible una relación del dominio. Si solo demuestran que el sistema de tipos puede resolver un acertijo, el coste de mantenimiento probablemente supera el beneficio.

Documentación recomendada