Depurar TypeScript: lo que sobrevive a la compilación y lo que no
La configuración que hace depurable un proyecto de TypeScript, y las construcciones del lenguaje que se transforman lo suficiente como para confundirte en el depurador.
Depurar TypeScript en el navegador significa depurar el JavaScript resultante mientras miras el TypeScript original, y esa traducción funciona sorprendentemente bien hasta que se topa con las construcciones que el compilador transforma de verdad. Los tipos desaparecen sin dejar rastro, y eso está bien; los enumerados, los decoradores, los campos privados y la sintaxis de parámetros con modificador generan código que no se parece al que escribiste, y ahí el depurador enseña cosas que no están en tu fichero.
- Configurar la generación de mapas para que el depurador muestre el TypeScript original.
- Distinguir las construcciones que se borran de las que generan código adicional.
- Interpretar el panel de scope cuando muestra variables que no escribiste.
- Diagnosticar la desalineación entre el fuente y el ejecutado en un proyecto con varios pasos.
La configuración mínima
Tres opciones del compilador deciden la experiencia.
sourceMap en verdadero genera un fichero .js.map junto a cada .js, con el comentario correspondiente añadido al final del generado.
inlineSources en verdadero incrusta el contenido de los ficheros .ts dentro del mapa, en sourcesContent. Sin ella, el navegador tiene que descargar los .ts originales, cosa que en un despliegue normal no puede hacer, y el editor aparece vacío.
inlineSourceMap incrusta el mapa entero dentro del .js como un comentario con datos en base64. Evita una petición adicional y engorda mucho el fichero servido; tiene sentido en desarrollo y rara vez en producción.
{
"compilerOptions": {
"sourceMap": true,
"inlineSources": true,
"target": "es2022",
"module": "esnext"
}
}
El valor de target importa más de lo que parece para depurar. Cuanto más bajo sea el objetivo, más transformaciones aplica el compilador y menos se parece el resultado a tu código. Con un objetivo moderno, async y await se mantienen tal cual; con uno antiguo se convierten en máquinas de estado con generadores, y el paso a paso deja de tener sentido.
En un proyecto con empaquetador, la configuración del compilador de TypeScript puede no ser la que manda: muchos empaquetadores usan su propio transformador y tienen su propia opción de mapas. Si has activado sourceMap y no ves mapas, el sitio donde mirar es la configuración del empaquetador, no la del compilador.
Lo que se borra sin dejar rastro
La mayor parte de TypeScript es borrado de tipos y no produce ningún código. Todo esto desaparece por completo.
Las anotaciones de tipo en variables, parámetros y retornos. Las interfaces y los alias de tipo. Los genéricos. Las declaraciones declare. Las importaciones que solo se usan como tipo. Los modificadores de acceso public, private y protected en su forma de solo anotación. Las aserciones con as y con el operador de no nulidad.
Que se borren tiene una consecuencia práctica que conviene tener presente: el depurador no sabe nada de tus tipos. Si un valor llega con la forma equivocada, no hay ninguna comprobación que salte; el tipo era una promesa del compilador y en tiempo de ejecución no queda nada de ella. Esto es exactamente por qué los datos que vienen de fuera —de una API, de un almacenamiento, de un formulario— tienen que validarse en tiempo de ejecución aunque estén tipados.
Lo que genera código
Cinco construcciones producen JavaScript que no se parece a lo que escribiste, y verlas en el depurador desconcierta si no lo esperas.
Los enumerados. Un enum compila a un objeto con una asignación bidireccional: las claves apuntan a los valores y los valores a las claves. En el panel de scope verás un objeto con el doble de entradas de las que declaraste. Los const enum se sustituyen en línea y desaparecen, lo cual es mejor para el tamaño y peor para depurar, porque en el código generado solo hay números sin ningún nombre.
Los parámetros con modificador de acceso. Escribir constructor(private nombre: string) genera una asignación en el cuerpo del constructor que tú no escribiste. Al recorrer el constructor con el paso a paso, verás líneas que no están en tu fichero.
Los decoradores. Generan llamadas a funciones auxiliares que envuelven la clase o el método. La pila de llamadas al entrar en un método decorado pasa por esas envolturas, y con objetivos antiguos por una función auxiliar del compilador con un nombre que empieza por guion bajo.
Los campos de clase privados. Los que usan el modificador de TypeScript son solo una comprobación de compilación y en tiempo de ejecución son propiedades normales, así que se ven en el panel. Los que usan la sintaxis nativa con almohadilla son de verdad privados y el depurador los muestra en una sección aparte.
Los espacios de nombres. Compilan a una función autoejecutada que construye un objeto. Un namespace produce un marco adicional en la pila.
Cuando el panel de scope o el paso a paso te enseñen algo que no reconoces, la comprobación es abrir el fichero generado —no el reconstruido por el mapa— y mirar qué hay ahí. El árbol de Sources tiene ambos.
Los ficheros auxiliares del compilador
Con objetivos antiguos, el compilador inserta funciones auxiliares para implementar características que el objetivo no soporta: propagación de objetos, herencia, decoradores, generadores asíncronos. Aparecen con nombres reconocibles al principio de cada fichero, o extraídas a un módulo compartido si se activa la opción correspondiente.
Esas funciones son ruido puro para depurar y son candidatas perfectas para la lista de ignorados. Con el módulo compartido activado, basta con ignorar ese único fichero.
El proyecto con varios pasos
El caso donde más falla la experiencia. Si tu compilación transpila TypeScript, luego empaqueta, y luego minifica, hay tres mapas que deben componerse en uno.
La comprobación de que la composición funciona es directa: abre el mapa final y mira su array sources.
Si contiene rutas terminadas en .ts, la composición es correcta.
Si contiene rutas terminadas en .js con nombres de artefactos intermedios, alguna herramienta de la cadena rompió la cadena y estás viendo código transpilado en vez de tu fuente.
// Que ficheros originales declara el mapa
fetch('/assets/app.min.js.map')
.then(r => r.json())
.then(m => console.table(m.sources.map(s => ({ fuente: s, tipo: s.split('.').pop() }))));
Si esa tabla está llena de ts, todo va bien. Si está llena de js, hay que revisar qué paso de la compilación no está consumiendo el mapa de entrada.
Hay una intuición muy extendida que conviene desmontar: la de que un proyecto bien tipado necesita depurar menos. El razonamiento es que si los tipos son correctos, una clase entera de errores desaparece, y eso es cierto. Lo que no se sigue es la conclusión, y el motivo es que el compilador solo garantiza lo que puede ver, y las fronteras de tu aplicación son precisamente lo que no puede ver. La respuesta de una API tipada con una interfaz es una promesa que nadie comprueba en tiempo de ejecución: si el servidor cambia un campo, tu código sigue creyendo que está ahí y falla varias capas más abajo, en un sitio donde el tipo dice que todo es correcto. Lo mismo con lo que sale de localStorage, con lo que llega por postMessage, con lo que devuelve una librería sin tipos, y con cualquier uso de as que alguien escribió para callar al compilador. Y ahí está la parte incómoda: cuando un dato mal formado se cuela en un sistema tipado, el bug resultante es más difícil de encontrar que en uno sin tipos, porque tu confianza en que el valor tiene la forma correcta es mucho mayor y por tanto no lo compruebas. Pasas media hora buscando en la lógica un error que no está ahí, mientras el dato lleva desde el principio con un campo null que el tipo prometía que nunca sería null. La conclusión operativa es doble. Primera: valida en tiempo de ejecución en todas las fronteras, con un validador de esquemas o a mano, y trata cualquier as como deuda; el tipo describe la intención y el validador comprueba la realidad. Segunda: cuando un valor te sorprenda mientras depuras, míralo de verdad en el panel de scope antes de razonar sobre lo que su tipo dice que debería ser. El depurador enseña lo que hay; el tipo enseña lo que alguien prometió. Cuando ambos discrepan, el que tiene razón es el depurador, siempre, y la promesa rota es el bug.