En el capítulo de conditional types viste un fragmento como este al final:
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 tipos —conditional 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:
// 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:
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:
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:
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.
// 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:
// 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.
Paso 2: Segundo test negativo
- Añades un segundo test negativo: afirmas que `Heroe["partidas"]` no acepta un string.
- Entiendes que si alguien añadiera "Soporte" a Rol en el futuro, el primer test negativo pasaría a rojo al instante.
- Los cuatro tests (dos positivos, dos negativos) sin subrayado.
Paso 3: Cazar un tipo roto
- Arreglas HeroeResumen: cambias victorias de string a number.
- Los tests _T4a y _T4b pasan a verde.
- Ves de primera mano cómo un test de tipos caza un tipo mal definido antes de que llegue a producción.
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".
// 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 positivo: Heroe tiene exactamente esas cuatro propiedades.
type _T1 = Expect<Equal<Heroe, { nombre: string; rol: Rol; partidas: number; victorias: number }>>;
// Test positivo: "rol" es Rol, no string ni ningún supertipo.
type _T2 = Expect<Equal<Heroe["rol"], Rol>>;
// Test negativo: la directiva de la línea siguiente afirma que la asignación DEBE fallar.
// "Soporte" no pertenece a Rol ("Daño" | "Tanque" | "Apoyo"), así que TypeScript
// rechaza la asignación. Si alguien añadiera "Soporte" a Rol, la directiva
// se marcaría como "unused" (el error ya no existe) y el test pasaría a rojo.
// Es decir: los tests negativos protegen los LÍMITES del tipo, no solo su contenido.
// @ts-expect-error
const _rolInvalido: Heroe["rol"] = "Soporte";
// Otro test negativo: "partidas" es number, así que asignar un string falla.
// @ts-expect-error
const _partidasInvalido: Heroe["partidas"] = "muchas";
console.log("Tests positivos y negativos en verde: los contratos del tipo están vigilados."); Por qué es mejor que el anterior
- El segundo test negativo (partidas no acepta un string) refuerza la idea: no solo el contenido del tipo está vigilado, también los tipos de sus propiedades.
- Los tests negativos son especialmente útiles cuando el tipo puede ampliarse en el futuro: si Rol añade "Soporte", la directiva avisa de que alguien que antes era inválido ahora es válido, y eso puede ser un fallo de lógica.
- El salto a excelente es usar todo esto para detectar un tipo que ya está mal definido.
// 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;
};
// Tests positivos: forma completa y acceso a propiedades concretas.
type _T1 = Expect<Equal<Heroe, { nombre: string; rol: Rol; partidas: number; victorias: number }>>;
type _T2 = Expect<Equal<Heroe["rol"], Rol>>;
// Tests negativos: la directiva siguiente protege los límites del tipo.
// @ts-expect-error
const _rolInvalido: Heroe["rol"] = "Soporte";
// @ts-expect-error
const _partidasInvalido: Heroe["partidas"] = "muchas";
// ─── El tipo roto arreglado ───────────────────────────────────────────────────
// HeroeResumen tenía "victorias: string". Los tests de abajo estaban en ROJO.
// Cambiar string → number los pone en verde.
// Ese es el valor de los tests de tipos: el error no aparece donde se usa
// HeroeResumen (quizá en otro fichero, semanas después), sino AQUÍ, al instante.
type HeroeResumen = {
nombre: string;
victorias: number;
};
// Ahora ambos tests pasan: victorias es number y HeroeResumen coincide con
// Pick<Heroe, "nombre" | "victorias"> exactamente.
type _T4a = Expect<Equal<HeroeResumen["victorias"], number>>;
type _T4b = Expect<Equal<HeroeResumen, Pick<Heroe, "nombre" | "victorias">>>;
console.log("Batería completa en verde: tipos protegidos contra refactors silenciosos."); Por qué es mejor que el anterior
- Arreglar HeroeResumen y ver los tests pasar a verde es el momento más didáctico: ves de primera mano cómo un test de tipos caza un error que sin él llegaría silencioso a producción.
- Pick<Heroe, "nombre" | "victorias"> es un utility type del Nivel 5 que ya conoces; aquí lo usas como referencia en un test para asegurar que HeroeResumen tiene exactamente esas dos propiedades con esos dos tipos.
- Esta es la batería mínima que pondrías en un proyecto real al definir un tipo importante: un test de forma completa, tests de propiedades clave, y tests negativos para los límites.