learning-front

Extra · Type-level TypeScript (opcional)

Testear a nivel de tipos

Cómo fijar que un tipo es exactamente el esperado con Equal<A,B> y Expect<T>, y cómo afirmar que algo NO debe compilar con @ts-expect-error.

En el capítulo de conditional types viste un fragmento como este al final:

typescript
type Equal<A, B> =
  (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

Se usaba para comprobar que los tipos que implementabas eran correctos. Pero no se explicó de dónde salía. Este capítulo es esa explicación.

Por qué hace falta testear tipos#

Cuando escribes código de tiposconditional types, utility types propios, transformaciones de interfaces— puedes equivocarte igual que en código de valores. La diferencia es que TypeScript no tiene un test runner para tipos; el compilador es el test runner.

La idea es simple: si escribes una expresión de tipo que solo compila cuando el tipo es el esperado, tienes un test. Si el tipo cambia por un refactor, el test pasa a rojo al instante, no semanas después cuando alguien intente usar el tipo y el error aparezca en un fichero completamente distinto. Ese es el «¿y qué?» de los tests de tipos: sin ellos, un tipo mal definido falla en silencio, lejos del foco, tarde.

Expect<T extends true>#

La mitad más sencilla del par:

typescript
// Solo compila si T es exactamente true.
// Si T es false, el compilador emite: "false no satisface la restricción true".
type Expect<T extends true> = T;

T extends true es una restricción de genérico: TypeScript exige que quien llame a Expect pase un tipo que sea true. Si le llega false, falla. Si le llega boolean (que incluye false), también falla. Solo true pasa.

En aislamiento, Expect<true> no sirve de mucho —siempre sabes que true es true—. Su utilidad viene combinada con Equal, que calcula dinámicamente si dos tipos coinciden y devuelve el booleano de tipos que Expect espera.

Equal<A, B>: cómo se usa#

Equal<A, B> devuelve true si A y B son el mismo tipo, y false si no:

typescript
type Equal<A, B> =
  (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

Combinado con Expect, forma un test completo:

typescript
type Rol = "Daño" | "Tanque" | "Apoyo";

// Compila en silencio: Rol ES "Daño" | "Tanque" | "Apoyo".
type _T1 = Expect<Equal<Rol, "Daño" | "Tanque" | "Apoyo">>;

// Error en compilación: Rol no es solo "Daño".
// type _T2 = Expect<Equal<Rol, "Daño">>;

La convención es nombrar los tests con _T1, _T2, etc. (el guion bajo indica que son solo para el compilador, no para runtime). Si el test falla, el subrayado rojo aparece exactamente en esa línea.

Por qué Equal usa funciones genéricas#

Podrías intentar implementar Equal con una comprobación mutua de extends:

typescript
type EqualNaif<A, B> = A extends B ? (B extends A ? true : false) : false;

Funciona en la mayoría de casos, pero falla con any y unknown. Ambos se asignan mutuamente —any extends unknown y unknown extends any son los dos true— pero no son el mismo tipo. EqualNaif<any, unknown> devuelve true, y eso es incorrecto.

La implementación real usa un truco: envuelve A y B dentro de funciones genéricas y luego compara esas funciones con extends. Cuando el compilador evalúa esa comparación, trabaja con la identidad completa del tipo, no solo con la asignabilidad superficial. Así Equal<any, unknown> devuelve false, que es lo correcto.

No hace falta que reconstruyas ese mecanismo a mano; lo importante es la intuición: Equal distingue tipos que son mutuamente asignables pero distintos.

@ts-expect-error: testear que algo NO compila#

Expect<Equal<...>> prueba que un tipo ES el esperado. Hay una segunda herramienta para el caso contrario: afirmar que una línea DEBE dar error de tipo.

typescript
// La directiva de la línea siguiente le dice al compilador:
// "espero que esto falle". Si NO falla, la directiva se marca como no utilizada (error).
// @ts-expect-error
const rolInvalido: Rol = "Soporte";

Si en el futuro alguien añadiera "Soporte" a Rol, la asignación dejaría de ser inválida y la directiva quedaría sin error que suprimir. TypeScript emite entonces TS2578: Unused '@ts-expect-error' directive, y el test pasa a rojo. Es decir: los tests negativos vigilan los límites del tipo, no solo su contenido.

La diferencia con @ts-ignore es esa: @ts-ignore silencia el error sin comprobación —si el error desaparece, @ts-ignore no avisa—. @ts-expect-error es estricto: exige que el error exista.

El valor real: atrapar refactors silenciosos#

Sin tests de tipos, un refactor puede romper un tipo en silencio. Cambias victorias: number por victorias: string en HeroeResumen, y el error no aparece en HeroeResumen.ts: aparece semanas después en otro fichero donde alguien suma victorias + 1 y obtiene "42" en vez de 43. TypeScript avisó —con el cambio de tipo, la suma no da error: solo da un resultado inesperado porque string + number devuelve string—.

Con un test de tipos:

typescript
// Este test pasa a rojo en el momento del refactor, no semanas después.
type _T = Expect<Equal<HeroeResumen["victorias"], number>>;

El error aparece inmediatamente, en el fichero de tipos, donde tiene contexto. Eso es lo que hace que los tests de tipos sean útiles en código real: no son un ejercicio académico, son una red de seguridad que detecta el problema donde se introduce, no donde se manifiesta.

Comprueba lo que sabes#

Pregunta 1 de 4

¿Qué hace `Expect<T extends true> = T`?

Tu turno#

Escribe tests de tipos para el Team Builder: tests positivos con Expect<Equal<...>>, un test negativo con la directiva, y arregla un tipo roto para que su batería de tests pase a verde. Cuando lo tengas (o si te atascas), despliega las soluciones y fíjate en el salto de un nivel al siguiente.

Ejercicio · en esta página

Escribe tests de tipos para el Team Builder

Trabajas a nivel de tipos: escribe tests con Expect<Equal<...>> y @ts-expect-error para proteger el contrato de Heroe. El último nivel te pide arreglar un tipo roto para que sus tests pasen a verde.

Paso 1: Tests positivos y un negativo

  • Escribes _T1 = Expect<Equal<Heroe, { nombre: string; rol: Rol; partidas: number; victorias: number }>>.
  • Escribes _T2 = Expect<Equal<Heroe["rol"], Rol>>.
  • Añades la directiva justo antes de `const _rolInvalido: Heroe["rol"] = "Soporte"` para que el test negativo compile.
  • Los dos tests positivos y el negativo quedan sin subrayado rojo.
Ver soluciones
// Helpers de testing de tipos.
type Equal<A, B> =
  (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

// Tipos del Team Builder.
type Rol = "Daño" | "Tanque" | "Apoyo";

type Heroe = {
  nombre: string;
  rol: Rol;
  partidas: number;
  victorias: number;
};

// Test 1: Heroe es exactamente ese objeto con esas cuatro propiedades.
// Si alguien añade o quita una propiedad a Heroe, este test pasa a rojo al instante.
type _T1 = Expect<Equal<Heroe, { nombre: string; rol: Rol; partidas: number; victorias: number }>>;

// Test 2: la propiedad "rol" de Heroe es exactamente Rol (no string ni any).
// Heroe["rol"] es la sintaxis de acceso indexado que da el tipo de una propiedad.
type _T2 = Expect<Equal<Heroe["rol"], Rol>>;

// Test 3: afirmamos que asignar "Soporte" a Heroe["rol"] NO compila.
// La directiva de la línea siguiente le dice al compilador: "espero un error aquí".
// Si la línea siguiente NO diera error, la directiva se marcaría en rojo (no utilizada).
// @ts-expect-error
const _rolInvalido: Heroe["rol"] = "Soporte";

console.log("_T1 y _T2 sin subrayado: los tipos de Heroe son los esperados.");

Por qué este nivel

  • Los tests positivos _T1 y _T2 son la forma más directa de fijar un contrato: si alguien cambia la forma de Heroe, el test pasa a rojo al instante.
  • La directiva antes de _rolInvalido es el primer test negativo: afirma que "Soporte" no es un Rol válido. Si el tipo cambiara para incluirlo, la directiva quedaría sin usar y el build avisaría.
  • El siguiente nivel añade un segundo negativo y hace explícita la idea de "proteger los límites del tipo".