Los límites de TypeScript: dónde empieza el test
Un nivel dedicado a hacer irrepresentable lo inválido debe cerrarse con la lección que casi nadie da: qué queda fuera del alcance del tipo. Aquí se examinan las tres fuentes de límite —el borrado completo de los tipos en tiempo de ejecución, los agujeros deliberados que TypeScript aceptó para poder describir JavaScript real, y las invariantes temporales y de dominio que ningún sistema de tipos práctico expresa— y se traduce cada una en una decisión concreta sobre qué cubrir con tests. El resultado es un reparto de trabajo nítido: el tipo gobierna la forma y las combinaciones posibles, el test gobierna el comportamiento a lo largo del tiempo, y confundir sus competencias produce a la vez código sobretipado e infratesteado.
Después de cuatro lecciones sumando casos, contando habitantes, marcando valores y analizando fronteras, existe el riesgo de salir del nivel con una idea peligrosa: que si el tipo compila, el programa es correcto. No lo es, y la distancia entre las dos cosas es exactamente lo que esta lección mide. TypeScript desaparece por completo al compilar, así que ninguna de sus promesas se sostiene sola en ejecución; aceptó además varios agujeros deliberados para poder describir el JavaScript que la gente ya escribía, de modo que hay programas que compilan y son inseguros por diseño del lenguaje, no por descuido tuyo; y hay una familia entera de reglas —las que hablan de orden, de tiempo, de dinero y de historia— que ningún sistema de tipos práctico alcanza a expresar. Conocer esos tres límites no debilita nada de lo aprendido: lo coloca. Saber dónde termina la garantía del tipo es lo que te dice, sin adivinar, dónde tiene que empezar el test.
- Explicar por qué el borrado de tipos en ejecución deja sin protección todo lo que cruza una frontera.
- Identificar los agujeros deliberados del sistema de tipos y el riesgo real que introduce cada uno.
- Reconocer las invariantes temporales y de dominio que quedan fuera del alcance de un tipo razonable.
- Repartir el trabajo entre tipos y tests, y escribir tests que cubran exactamente lo que el tipo no puede.
Lo que desaparece al compilar
El primer límite es estructural y no admite mitigación: los tipos no existen en tiempo de ejecución. El compilador comprueba, borra y emite JavaScript sin una sola instrucción derivada de tus anotaciones.
De ahí se siguen tres consecuencias que conviene tener presentes a la vez. La primera ya la trabajaste en la lección anterior: todo lo que entra de fuera necesita un análisis real, porque la anotación no comprueba nada.
La segunda es que no puedes preguntar en ejecución por un tipo declarado. No hay manera de saber si un valor es un Usuario sin escribir tú mismo el código que lo mire campo a campo, y por eso el discriminante de las uniones de la primera lección es tan valioso: es la única parte del tipo que sobrevive al borrado, porque es un dato de verdad.
Esa segunda consecuencia tiene una lectura de diseño que conviene guardar. Si necesitas saber en ejecución de qué caso se trata, el dato tiene que llevarlo escrito, y por eso las uniones bien discriminadas se comportan igual de bien en la compilación y en el registro de trazas: el mismo campo que estrecha el tipo aparece en el log y dice sin ambigüedad en qué estado estaba la pantalla cuando algo falló.
La tercera es la más incómoda: los mensajes de error de tus usuarios no mencionarán tipos. Cuando algo falle, fallará como falla JavaScript, con una propiedad indefinida o una función que no existe, y el tipo que prometía que eso no podía pasar no dejará ni rastro en la traza.
Esa es también la razón por la que la técnica de la marca fantasma funciona sin coste y, a la vez, sin defensa en ejecución. Un valor marcado es en ejecución una cadena corriente; si alguien lo fabricó con una conversión forzada, no hay nada en el programa que pueda notarlo después.
La marca protege del olvido durante la escritura del código, que es donde ocurren casi todos los errores, pero no es un guardia que patrulle mientras el programa corre.
El tipo gobierna la forma
Qué campos existen, qué combinaciones son posibles, qué encaja con qué. Se comprueba una vez, cubre todos los caminos y no cuesta nada en ejecución.
El test gobierna el comportamiento
Qué ocurre en qué orden, qué se conserva tras una secuencia, qué valores concretos salen. Se comprueba ejecutando, y solo cubre lo que ejecutas.
Los agujeros deliberados
El segundo límite no viene del borrado sino de las concesiones que TypeScript hizo a propósito. Conviene conocerlas una a una, porque cada una es un sitio donde el compilador deja de responder sin avisarte.
El primero es el escape explícito: any apaga la comprobación en todo lo que toca y se propaga en silencio a través de los valores que produce. Su primo cortés es la aserción de tipo, que no convierte nada sino que ordena al compilador creerte, y el operador de aserción no nula, que afirma que algo existe sin comprobarlo. Los tres son necesarios en ocasiones contadas y los tres deben leerse como lo que son: una nota firmada que dice que ahí asumes tú la responsabilidad.
El segundo es el acceso por índice. Leer un elemento de una lista por su posición o una clave de un diccionario devuelve, por defecto, el tipo del elemento, aunque esa posición o esa clave no existan: el tipo dice que hay un valor donde en ejecución hay undefined. La opción de compilador que añade el undefined a esas lecturas corrige el punto y merece activarse, pero conviene saber que sin ella cada acceso indexado es una pequeña mentira.
El tercero es la varianza permisiva. Una lista de un subtipo se acepta donde se espera una del supertipo, lo que permite insertar en ella un elemento que rompe la promesa original, y los parámetros de los métodos declarados con la sintaxis abreviada se comparan de forma bivariante por compatibilidad histórica con el código existente. Ninguna de las dos cosas es un descuido: son decisiones tomadas para que el lenguaje pudiera describir bibliotecas reales.
El cuarto es la propagación de lo desconocido desde bibliotecas sin tipos o con tipos escritos a mano por terceros, que pueden describir una versión distinta de la que tienes instalada. Ahí el tipo es la opinión de alguien ajeno sobre un código que quizá ya cambió.
const usuarios: Usuario[] = [];
const primero = usuarios[0]; // el tipo dice Usuario; en ejecución es undefined
primero.nombre.trim(); // compila y revienta
const emails: Record<string, string> = {};
emails["ausente"].toLowerCase(); // el tipo promete una cadena que no existe
const puntuaciones: number[] = [1, 2, 3];
const cualquiera: unknown[] = puntuaciones; // varianza permisiva
cualquiera.push("texto"); // la lista de números ya no lo es
Ninguna de esas tres líneas es un descuido de quien las escribió: las tres compilan porque el lenguaje decidió que así fuera. Conocerlas de memoria cambia la manera de leer código ajeno, porque enseña a mirar con desconfianza justo los sitios donde el compilador ha dejado de mirar.
Es tentador leer un build en verde como una demostración de corrección, y no lo es ni pretende serlo. TypeScript nunca aspiró a ser un sistema de tipos sólido: aspiró a ser útil sobre JavaScript real, y para eso aceptó agujeros a cambio de adopción. La lectura correcta de una compilación limpia es que no hay errores de la clase que el compilador sabe detectar, con las opciones que tienes activadas y con la honestidad de las aserciones que alguien escribió. Es muchísimo, y no es una prueba. Activar el modo estricto y las opciones adicionales sobre índices y propiedades opcionales estrecha bastante esos márgenes, y merece hacerse en cualquier proyecto que aspire a durar.
Las invariantes que ningún tipo alcanza
El tercer límite es el más interesante, porque no se arregla con opciones del compilador ni con más disciplina: hay verdades sobre tu dominio que un tipo, sencillamente, no habla.
La familia mayor es la del tiempo y el orden. Un tipo describe qué valores son posibles, no en qué secuencia ocurren. Que un pedido no pueda pagarse dos veces, que una cancelación no llegue antes que su creación, que una respuesta obsoleta no pise a la actual cuando el usuario teclea rápido: todas son afirmaciones sobre historias, y una historia no es un valor.
La segunda familia es la de las relaciones aritméticas y de consistencia. Que el total sea la suma de las líneas menos el descuento, que el saldo nunca baje de cero, que la lista de la vista tenga los mismos elementos que la del store. Podrías codificar algunas con aritmética a nivel de tipos y el resultado sería una firma que nadie sabe leer y que sigue sin cubrir el caso general.
La tercera es la del significado. El tipo garantiza que la función devuelve un número, no que sea el número correcto; garantiza que hay una lista ordenada por el tipo que le pusiste, no que el algoritmo de ordenación funcione. El compilador comprueba coherencia, no verdad.
Y hay una cuarta, la de las propiedades del sistema completo: que dos réplicas converjan, que un reintento sea idempotente, que un efecto se cancele al desmontar. Nada de eso vive en la forma de un dato.
flowchart TD
P[Que quiero garantizar] --> Q1{Es una regla sobre la forma o las combinaciones}
Q1 -- si --> T[Tipo: union discriminada marca esquema]
Q1 -- no --> Q2{Depende del orden del tiempo o del calculo}
Q2 -- si --> X[Test: unitario de propiedades o basado en modelos]
Q2 -- no --> R[Revisar: quiza sea una regla sin dueno]
style T fill:#a6e3a1,color:#11111b
style X fill:#f9e2af,color:#11111b
style R fill:#f38ba8,color:#11111bDónde empieza el test
Con los tres límites sobre la mesa, la frontera entre tipar y testear deja de ser cuestión de gusto y se vuelve deducible: se testea exactamente lo que el tipo no puede afirmar.
De ahí salen cuatro categorías con nombre propio. Las transiciones: que desde el estado de revisión y con la acción de confirmar se llegue al estado de envío y no a otro, algo que la unión discriminada permite representar pero no obliga a respetar.
Los cálculos: cualquier función que produzca un número, una fecha o un texto formateado, donde el tipo solo dice de qué clase es el resultado. Las secuencias: reintentos, cancelaciones, carreras entre respuestas y toda la fenomenología del nivel de concurrencia. Y las invariantes globales, que se afirman sobre el estado completo después de cualquier operación.
Para las dos últimas, la herramienta que rinde de verdad no es el test de ejemplo sino el de propiedades. En lugar de escribir un caso concreto, se declara una afirmación que debe cumplirse para toda entrada —aplicar cualquier secuencia de acciones deja el total igual a la suma de las líneas— y se deja que la herramienta genere cientos de secuencias y reduzca al mínimo la que falle.
Es el complemento natural de los tipos: donde el tipo enumera las formas posibles, la prueba de propiedades explora las historias posibles, y entre las dos cubren los dos ejes por los que un estado puede corromperse.
// El tipo garantiza la forma del estado; esta propiedad garantiza su coherencia
test("el total siempre iguala la suma de líneas menos el descuento", () => {
comprobarParaTodo(secuenciasDeAcciones(), (acciones) => {
const final = acciones.reduce(reducir, estadoInicial);
const suma = final.lineas.reduce((a, l) => a + l.precio * l.unidades, 0);
return final.total === suma - final.descuento;
});
});
Observa qué se está afirmando ahí y por qué ningún tipo podría hacerlo. La propiedad no habla de un valor concreto sino de una relación que debe sobrevivir a cualquier historia, y para comprobarla hay que ejecutar el reducer muchas veces con secuencias que nadie escribió a mano.
Ese es el punto exacto donde termina la competencia del tipo y empieza la del test, y por eso conviene escribir estas propiedades justo después de terminar el modelado: en ese momento tienes fresca la lista de reglas que no cupieron en el tipo, y es la mejor especificación de tests que vas a tener nunca.
Hay un beneficio final que solo aparece cuando el tipado es bueno, y merece nombrarse porque es el que cierra el círculo del nivel. Cuando el estado está modelado con exactitud, la lista de tests que hay que escribir se acorta y se aclara al mismo tiempo.
Ya no hace falta testear que no lleguen a la vez datos y error, porque es irrepresentable; ni que el correo esté validado antes de enviarlo, porque el tipo lo prueba; ni que falte un campo obligatorio, porque el esquema lo rechazó en la frontera. Lo que queda son los tests que hablan de comportamiento, que son los que valía la pena escribir. Un tipado exacto no sustituye a la suite: la depura.
Si tu proyecto expone tipos complejos —uniones discriminadas grandes, marcas, tipos derivados de esquemas—, esos tipos son código y pueden romperse en un refactor sin que nada se ponga en rojo. Existen utilidades que afirman en tiempo de compilación que un tipo es asignable a otro, o que una expresión no compila, y ejecutar esas afirmaciones como parte de la suite convierte tus garantías de tipado en algo verificable. La comprobación de exhaustividad con never de la primera lección es la versión mínima y gratuita de esta idea: un test de tipos que se ejecuta en cada compilación.
Quédate con esta división de competencias porque resuelve dos errores opuestos que se cometen con la misma frecuencia y son igual de caros. El primero es el del ingeniero que descubre el poder del sistema de tipos y trata de meterlo todo dentro: acaba con firmas que ocupan pantallas, con aritmética a nivel de tipos que nadie del equipo sabe modificar y con una sensación de seguridad que se evapora en cuanto un dato entra sin analizar por una frontera que olvidó, porque los tipos jamás vigilaron lo que pasa en ejecución. El segundo es el del que, escarmentado por ese exceso o por no haber conocido nunca la alternativa, renuncia a modelar y lo cubre todo con tests: acaba con una suite enorme que comprueba a mano, caso por caso, combinaciones que un buen tipo habría vuelto imposibles de escribir, y que se rompe entera en cada refactor porque cada test conoce demasiados detalles. La salida no es un punto medio difuso sino un criterio: el tipo responde a qué es posible construir y el test responde a qué ocurre cuando se ejecuta, y ninguna de las dos preguntas se contesta con la herramienta de la otra. Modela primero, porque cada estado que vuelves irrepresentable es un test que ya no hay que escribir ni mantener; testea después, porque cada regla que habla de orden, de tiempo o de resultado no cabe en un tipo por mucho que la aprietes. Y no olvides el borde donde todo esto se sostiene: la garantía del tipo termina en la frontera, y lo que decides confiar dentro solo es confiable si alguien lo comprobó al entrar. Con eso cierras el nivel y abres el siguiente, porque testear la lógica de estado sin montar la interfaz es exactamente el asunto que viene ahora.
- Revisa el estado de una pantalla tuya y clasifica sus reglas en dos columnas: las que hablan de forma y combinaciones, y las que hablan de orden, tiempo o cálculo.
- Comprueba cuántas reglas de la primera columna están ya garantizadas por el tipo y modela las que no lo estén.
- Recorre el código en busca de
any, aserciones de tipo y aserciones de no nulidad, y justifica por escrito cada una o elimínala. - Activa el modo estricto y las opciones sobre acceso indexado y propiedades opcionales, y anota qué clase de fallo revela cada una.
- Escribe un test para una regla de la segunda columna que hoy nadie comprueba, empezando por una invariante que deba cumplirse tras cualquier secuencia de acciones.
- Cuenta cuántos tests de tu suite comprueban situaciones que tu tipo ya vuelve irrepresentables y bórralos: ese número mide lo que acabas de ganar.