¿Por Qué any Hace que Pierdas el Tipo que Pasaste?

Tipa un parámetro como any y TypeScript deja de comprobar todo lo que sale de la función:

1
2
3
4
5
6
function firstElement(arr: any[]): any {
  return arr[0];
}

const n = firstElement([1, 2, 3]);
n.toUpperCase(); // compila, falla en tiempo de ejecución

Tú sabes que pasaste un array de números. El compilador no, porque any le dice que deje de mirar. n queda tipado como any, así que llamar a un método de string sobre un número compila sin problemas y lanza TypeError: n.toUpperCase is not a function cuando se ejecuta.

Un generic nombra el tipo en lugar de descartarlo:

1
2
3
4
5
6
7
function firstElement<T>(arr: T[]): T {
  return arr[0];
}

const n = firstElement([1, 2, 3]); // n: number
const s = firstElement(["a", "b"]); // s: string
n.toUpperCase(); // error: Property 'toUpperCase' does not exist on type 'number'

T es un parámetro de tipo, un comodín que se rellena en cada llamada. firstElement([1, 2, 3]) rellena T con number, el tipo de retorno se convierte en number, y la llamada incorrecta a .toUpperCase() falla en tu editor en lugar de en producción. Escribes la función una sola vez y cada tipo de elemento mantiene su propia comprobación.

El resto de esta guía trata de usarlos sin hacer que la firma sea más difícil de leer que el error que evitan.

¿Cómo Infiere TypeScript el Parámetro de Tipo?

No escribiste firstElement<number>([1, 2, 3]) arriba, y rara vez lo necesitas. TypeScript compara el argumento con la forma declarada del parámetro (T[]) y resuelve T. Es la misma inferencia que tipa const x = 5 como number, aplicada a los parámetros de tipo.

Sin nada de donde inferir, T cae en unknown:

1
2
3
4
5
6
function makeEmptyArray<T>(): T[] {
  return [];
}

const arr = makeEmptyArray(); // T es unknown, inferido como `unknown[]`
const nums = makeEmptyArray<number>(); // argumento de tipo explícito, nums: number[]

Cuando la llamada no tiene ningún valor del que inferir, pasa tú mismo el argumento de tipo entre corchetes angulares.

La inferencia también puede fallar cuando dos argumentos alimentan el mismo T y no coinciden. TypeScript elige un candidato y señala el otro argumento como el problema, lo que puede producir un error que nombra un tipo que nunca escribiste:

1
2
3
4
5
6
function longest<T>(a: T, b: T): T {
  return a;
}

longest([1, 2], "abc");
// error: Argument of type 'number[]' is not assignable to parameter of type '"abc"'

Cuando una llamada genérica da un error confuso o un tipo inesperado, pasa el cursor sobre la llamada en tu editor y lee qué se infirió para T. Normalmente eso explica el problema más rápido que leer el mensaje.

¿Cómo Restringir un Generic con extends y keyof?

Un T sin restricciones puede ser cualquier cosa, así que dentro de la función solo puedes hacer lo que todo tipo soporta, que es casi nada además de pasar el valor de un lado a otro. La mayoría de las funciones genéricas reales necesitan asumir algo sobre T, y extends es la forma de decirlo:

1
2
3
4
5
6
7
8
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const dev = { name: "Tobias", role: "backend", salary: 95000 };

getProperty(dev, "role"); // "backend", tipado como string
getProperty(dev, "pay"); // error: Argument of type '"pay"' is not assignable to parameter of type '"name" | "role" | "salary"'

K extends keyof T limita K a los nombres de las propiedades de lo que sea que pases, y T[K] lee el tipo de esa propiedad. La lista de claves válidas viene del objeto en el punto de llamada, no de una unión fija de antemano. Una errata como "pay" se convierte en un error de compilación en lugar de un undefined silencioso.

La restricción también puede ser una forma simple:

1
2
3
4
5
6
7
8
function logLength<T extends { length: number }>(value: T): T {
  console.log(value.length);
  return value;
}

logLength("hello"); // 5
logLength([1, 2, 3]); // 3
logLength(42); // error: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'

Tanto los strings como los arrays tienen length, así que ambos pasan. Un número no, y la llamada se rechaza antes de que el cuerpo de la función se ejecute siquiera. Como el tipo de retorno es T y no { length: number }, logLength("hello") te sigue devolviendo un string.

¿Cómo Funcionan las Interfaces y Clases Genéricas?

Una interface genérica es una forma con un parámetro de tipo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
interface Box<T> {
  value: T;
  unwrap(): T;
}

const numberBox: Box<number> = {
  value: 5,
  unwrap() {
    return this.value;
  },
};

Una clase genérica comparte su parámetro de tipo entre todos los campos y métodos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class Stack<T> {
  private items: T[] = [];

  push(item: T): void {
    this.items.push(item);
  }

  pop(): T | undefined {
    return this.items.pop();
  }

  peek(): T | undefined {
    return this.items[this.items.length - 1];
  }
}

const numbers = new Stack<number>();
numbers.push(1);
numbers.push(2);
numbers.peek(); // 2, tipado como number | undefined

new Stack<number>() y new Stack<string>() quedan totalmente tipados, y la clase existe una sola vez. El mismo patrón aparece en cualquier sitio donde uses dependency injection: escribes un Repository<T> una vez, y luego inyectas Repository<User> y Repository<Order> donde se necesiten en lugar de mantener una clase casi idéntica por cada entidad.

Varios parámetros de tipo funcionan igual, y cada uno puede tener su propia restricción:

1
2
3
4
5
function pair<K, V>(key: K, value: V): [K, V] {
  return [key, value];
}

pair("id", 42); // [string, number]

Dos parámetros son también la forma en que un tipo Result<T, E> agrupa un valor de éxito y un tipo de error. Si estás decidiendo si las funciones deben lanzar excepciones o devolver los fallos como datos, excepciones vs valores de error analiza esa decisión.

¿Cómo Funcionan los Parámetros de Tipo por Defecto?

Un parámetro de tipo puede tener un valor por defecto, igual que un parámetro de función puede tenerlo:

1
2
3
4
5
6
class ApiResponse<T = unknown> {
  constructor(public data: T, public status: number) {}
}

const raw = new ApiResponse({ ok: true }, 200); // T inferido como { ok: boolean }
const typed: ApiResponse<{ id: number }> = new ApiResponse({ id: 1 }, 200);

La inferencia sigue ganando cuando hay un argumento del que inferir, como muestra raw. El valor por defecto se aplica cuando nada rellena T, por ejemplo cuando escribes ApiResponse como anotación de tipo sin argumento de tipo. Eso hace que los valores por defecto sean útiles en wrappers como los tipos de respuesta y los payloads de eventos, donde a muchos puntos de llamada no les importa el tipo exacto de los datos. Usa unknown como valor por defecto, no any: sigue obligando a una comprobación antes de que alguien lea data.

¿Cuándo Usar un Union Type en Lugar de un Generic?

Hazte una pregunta antes de añadir <T>: ¿el tipo de retorno depende de lo que se pasó? Si la función devuelve siempre el mismo tipo sin importar la entrada, una unión basta y un generic no aporta nada:

1
2
3
4
// no hace falta un generic aquí: el tipo de retorno no sigue a la entrada
function describe(value: string | number): string {
  return typeof value === "string" ? value.toUpperCase() : value.toFixed(2);
}

firstElement, getProperty y Stack<T> son distintos. Cada uno devuelve algo cuyo tipo depende de lo que entró, y ese vínculo entre entrada y salida es lo que te da un generic. Si puedes escribir la función con una unión sin perder nada, usa la unión.

Acumular parámetros de tipo porque una función podría ser genérica cuesta legibilidad y no aporta nada. Un helper con tres parámetros de tipo que solo se llama con string y number es más difícil de leer que dos overloads. Añade <T> cuando un caller real necesita que un tipo fluya a través, y quítalo cuando cada punto de llamada fija el mismo tipo concreto.

¿Por Qué No Puedes Usar T en Tiempo de Ejecución?

Los parámetros de tipo se eliminan en tiempo de compilación. El JavaScript que se ejecuta no tiene ningún T, así que no puedes construir uno, comprobar instanceof T, ni ramificar según él:

1
2
3
4
function create<T>(): T {
  // return new T(); // error: 'T' only refers to a type, but is being used as a value here
  throw new Error("not implemented");
}

Si una función necesita construir un T o inspeccionarlo en tiempo de ejecución, pasa un valor real como argumento: un constructor, un esquema, o una etiqueta de tipo. Los generics son un contrato en tiempo de compilación, no una protección en tiempo de ejecución, así que los datos que llegan de fuera de tu programa (una respuesta de API, JSON.parse) siguen necesitando validación aunque estén tipados como ApiResponse<User>.

Para ver cada error de esta guía por ti mismo, instala TypeScript con el gestor que use tu proyecto, npm, Yarn o pnpm, pega los ejemplos en un archivo .ts y ejecuta npx tsc --noEmit --strict. Luego abre el any más usado en tu propio código y comprueba si un <T> haría fluir el tipo.

Artículos relacionados