Soporte nativo de Node para TypeScript: 7 problemas reales y sus soluciones
Aprenda qué características de TypeScript son dañadas silenciosamente por la eliminación automática de tipos integrada en Node en entornos de producción, y las configuraciones exactas que solucionan el problema, verificadas en Node 22.18+ y 24.x LTS.
Un equipo que migraba un pequeño servicio Express decidió eliminar ts-node y ejecutar la aplicación directamente con node file.ts en el entorno de pruebas. El cambio parecía funcionar bien en las pruebas locales, pero al día siguiente ya había fallos en el pipeline de CI; algunos desarrolladores recibían el error ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX en sus terminales, y se lanzó una corrección de emergencia para producción sin ningún tipo de verificación de tipos, ya que esa medida de seguridad había desaparecido silenciosamente.
El manejo nativo de TypeScript por parte de Node, a través del eliminado de tipos, es una funcionalidad sólida. Sin embargo, abarca solo una parte más limitada de TypeScript de lo que la mayoría de las personas suponen, y las deficiencias solo se vuelven evidentes cuando uno se topa con ellas en la práctica. A continuación se presentan siete problemas que surgieron durante una migración real, junto con los errores reales generados y las soluciones verificadas en Node 22.18+ y 24.x LTS.
La promesa vs. la realidad
Node ejecuta TypeScript eliminando las anotaciones de tipo en tiempo de ejecución mediante una copia integrada de swc. Nunca hace llamadas al compilador de TypeScript; no hay fase de tsc involucrada. En consecuencia, tampoco hay verificación de tipos, transformación sintáctica para versiones antiguas, resolución de alias de rutas, soporte para .tsx, soporte para decoradores, enums ni generación de código en tiempo de ejecución para espacios de nombres.
Ejecutar node file.ts funciona y es una capacidad real, pero representa solo la funcionalidad mínima, no el conjunto completo de características de TypeScript.
1. Mis importaciones relativas generan errores 404 en producción
El síntoma. Todo funcionaba localmente. Pero una vez desplegado, la aplicación fallaba al iniciar con un error similar a este:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/srv/app/dist/utils/hash.js'
imported from /srv/app/dist/server.js
Qué sucedió. El proceso de eliminación de tipos solo quita las anotaciones; deja intactos todos los demás tokens del archivo, incluidas las rutas de importación. Así que un archivo fuente como este:
// src/server.ts
import { hashToken } from "./utils/hash.js";
se pasa sin modificaciones. Durante el desarrollo local, node --experimental-strip-types fue lo suficientemente inteligente como para resolver ./utils/hash.ts a pesar de que la extensión indicaba .js. Pero el proceso de compilación (compilando con tsc en una carpeta dist) mantuvo la extensión literal .js en la cadena, y no había un archivo .js correspondiente dentro de dist/utils/ — solo se habían compilado fuentes .ts en otro lugar.
La solución. Se necesitan dos cambios separados aplicados juntos.
Primero, escriba la extensión de importación que coincida con lo que realmente está en el disco, es decir .ts, no .js:
// src/server.ts
import { hashToken } from "./utils/hash.ts";
Segundo, indique al compilador que esto es intencional y permítale traducir la extensión durante el proceso de emisión:
{
"compilerOptions": {
"noEmit": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "esnext",
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true
}
}
La configuración clave es rewriteRelativeImportExtensions: true, la cual convierte ./utils/hash.ts de nuevo en ./utils/hash.js cuando tsc genera la salida, de modo que el JavaScript compilado siga funcionando correctamente para cualquier aplicación que lo utilice. noEmit: true no es opcional aquí; sin él, allowImportingTsExtensions genera un error TS5096. Consulte la documentación de TypeScript para más detalles.
2. La mitad de mi códigobase tenía “sintaxis no soportada”
El síntoma. Un desarrollador se encontró con este error en su primer commit después del cambio:
TypeError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum declarations are
not supported by Node's type stripping. Convert enums to objects with `as const`
or use a transformer.
Otro se topó con un problema similar con el constructor de una clase:
TypeError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter properties
are not supported. Use an explicit field declaration instead.
Qué sucedió. El motor de eliminación de tipos de Node solo admite intencionalmente una categoría limitada de sintaxis: constructos que pueden eliminarse por completo sin alterar el comportamiento en tiempo de ejecución, conocidos como sintaxis “borrables”. Cualquier característica de TypeScript que genere realmente lógica en JavaScript en tiempo de ejecución es rechazada con una excepción en ese momento en lugar de ser transformada.
La lista completa de constructos no soportados, extraída de la documentación oficial de Node TypeScript, incluye: declaraciones enum, que deben convertirse en una unión de cadenas o en un objeto utilizando as const; bloques namespace que contienen lógica en tiempo de ejecución, y cuyos valores exportados deben trasladarse a exportaciones normales de módulo (los namespaces solo de tipo siguen siendo válidos); propiedades de parámetros en constructores (como constructor(public x: number)), que requieren una declaración de campo explícita en su lugar; alias de importación, que deben ser renombrados en el momento de la importación; decoradores, que fallan a nivel del analizador y no se completan mediante polyfills; y archivos .tsx, ya que solo se reconocen las extensiones .ts, .mts y .cts.
La solución. Así es como se resuelve el caso enum:
// before ; dies at runtime
enum Role { Admin = "admin", User = "user" }
// after ; works under type stripping AND in tsc
const Role = {
Admin: "admin",
User: "user",
} as const;
type Role = (typeof Role)[keyof typeof Role];
Para los decoradores, el enfoque más seguro es esperar a que el analizador de Node soporte nativamente la propuesta de decoradores TC39 antes de adoptarlos, o bien utilizar un transformador como swc o tsc en el proceso específicamente para los archivos que dependen de ellos. También vale la pena activar "erasableSyntaxOnly": true en tsconfig.json; esto hace que el compilador indique directamente en tu editor la sintaxis no soportada antes de que llegue al tiempo de ejecución.
3. La verificación de tipos no ocurre en silencio
El síntoma. Un manejador de producción aceptó un valor null donde se esperaba un string y colapsó al llamar a .length sobre él. La prueba unitaria para esa ruta pasó sin problemas. La variable estaba declarada como string, el valor real era null, y node file.ts ejecutó ambos sin objeciones.
app.post("/webhook", (req, res) => {
const body: string = req.body.payload; // null sneaks in, no one notices
console.log(body.length);
});
Qué sucedió. El proceso de eliminación de tipos funciona a nivel puramente textual; nunca consulta al verificador de tipos. Nada en el camino de ejecución de node verifica que los valores que fluyen a través del código coincidan con sus tipos declarados.
Se podría decir que este es el modo de fallo silencioso más riesgoso introducido al alejarse de ts-node. Un ejecución exitosa de node file.ts no indica si el código es correcto desde el punto de vista tipológico.
La solución. Vuelve a introducir la verificación de tipos como un paso separado y explícito.
// package.json
{
"scripts": {
"dev": "node --watch src/server.ts",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"ci": "npm run typecheck && npm run lint"
}
}
Ejecuta tsc --noEmit como parte del proceso de CI para cada solicitud de integración, y considera integrarlo en los ganchos pre-commit si eso se adapta a tu flujo de trabajo. El entorno de ejecución nativo procesa el código; tsc es la herramienta encargada de detectar errores de tipo. Estas dos funciones ahora están completamente desacopladas, y esa separación es intencionada en el diseño de Node.
También vale la pena activar "erasableSyntaxOnly": true junto con "verbatimModuleSyntax": true en tsconfig.json. La primera configuración hace que tsc rechace todo aquello que no pueda manejar el proceso de eliminación de tipos, detectando decoradores o enums en tiempo de compilación y no en tiempo de ejecución. La segunda exige el uso explícito de instrucciones import type para que las importaciones basadas únicamente en tipos no dejen atrás instrucciones de importación adicionales en tiempo de ejecución.
4. Mis alias de ruta @/utils/* dejaron de funcionar
El síntoma. Un error familiar:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '@/utils/logger'
imported from /srv/app/src/server.ts
Qué estaba sucediendo. El campo paths en tsconfig.json es puramente una conveniencia en tiempo de compilación para TypeScript — Node en sí mismo nunca lo ha comprendido. Herramientas como ts-node y tsx lo respetaron porque implementaron su propia lógica de resolución de módulos sobre Node. El proceso de eliminación de tipos nativos no hace eso; entrega directamente la resolución al cargador de Node.
// tsconfig.json ; this never worked at runtime, it only worked in your editor
{
"compilerOptions": {
"baseUrl": ".",
"paths": { "@/utils/*": ["src/utils/*"] }
}
}
La solución. Existen tres opciones válidas, dependiendo de cómo se despliegue su aplicación.
Opción A: utilizar las importaciones de subcamino integradas en Node. Elimine por completo los campos paths de tsconfig y declare la asignación en package.json en su lugar:
{
"imports": {
"#utils/*": "./src/utils/*"
}
}
// src/server.ts
import { logger } from "#utils/logger.ts";
Esto se resuelve correctamente bajo node, bajo tsc --noEmit y bajo vitest, sin necesidad de ninguna configuración adicional en ningún lugar. El # al principio es una convención propia de Node que indica “esto es un alias interno, no un paquete publicado”. Migrar simplemente significa realizar una búsqueda y reemplazo en todo el proyecto de @/utils/ a #utils/.
Opción B: recurrir a importaciones relativas y trabajar con cadenas de ../. Es tedioso, pero no implica herramientas ocultas.
Opción C: mantener un transformador de reescritura de rutas. Herramientas como tsc-alias reescriben el JavaScript generado después de la compilación, o bien se puede permitir que tsx/swc resuelvan los alias en tiempo de ejecución. Hacer esto vuelve a introducir el paso de compilación que se intentaba eliminar, lo cual resta gran parte del atractivo del TypeScript nativo. Esta no es una ruta que valga la pena seguir hacia 2026.
5. La interoperabilidad entre CommonJS y ESM me tomó por sorpresa
El síntoma. Una llamada require("openai") que siempre había funcionado de repente arrojó:
Error [ERR_REQUIRE_ESM]: require() of ES Module ... openai ... not supported.
O, en la dirección opuesta, una importación de directorio sin extensión falló:
Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import ... is not supported
under ESM
Qué estaba sucediendo. Anteriormente, su archivo fuente era compilado por tsc en un archivo .js dentro de dist/, y require() funcionaba como se esperaba. Con la ejecución nativa de TypeScript, el archivo que Node carga realmente es el propio archivo .ts, y Node determina si debe tratarlo como CommonJS o ESM según el campo "type" del package.json más cercano. Si ese campo indica "module", todos los archivos .ts en el ámbito son de tipo ESM, por lo que cualquier llamada restante a require() falla. Si ese campo falta (lo cual implica CommonJS por defecto), aparece el problema opuesto: la importación de una dependencia exclusiva para ESM falla.
La solución. Elija un sistema de módulos y aplíquelo en todo el proyecto.
Si estás comenzando desde cero, establece "type": "module" en package.json, escribe todo en formato ESM, y reserva las extensiones .mts/.cts para los archivos que realmente necesiten el otro formato.
// package.json
{
"type": "module",
"engines": { "node": ">=22.18.0" }
}
// src/server.ts
import { readFile } from "node:fs/promises"; // ESM, native
import OpenAI from "openai"; // pure ESM upstream
const openai = new OpenAI();
En un códigobase existente basado en CommonJS, mantén "type": "commonjs" (o omite ese campo) — y evita intentar utilizar un paquete puro ESM desde código CommonJS sin pasar por un import() dinámico. Node 22.12+ sí soporta require(esm) de forma estable, pero depender de él sigue exponiéndote a riesgos relacionados con el uso de dos formatos diferentes y hace que tu proceso de compilación sea frágil. Las opciones más seguras son convertir el archivo que lo llama al formato ESM, o envolver la dependencia en un import dinámico dentro de una función async.
Existe una segunda trampa aquí: las importaciones de directorios. Bajo ESM, escribir import x from "./folder" no resuelve automáticamente a ./folder/index.ts como solía ocurrir antes. Es necesario especificar el nombre del archivo de forma explícita:
// bad
import { routes } from "./routes";
// good
import { routes } from "./routes/index.ts";
6. El modo de vigilancia y el recargado en tiempo real dieron un paso atrás
El síntoma. Después de pasar de tsx watch src/server.ts a node --watch src/server.ts, varias cosas empeoraron:
- Velocidad de reinicio:
node --watchfunciona, pero es notablemente menos ágil. - Recargado fiable cuando se produce un cambio en un archivo importado fuera de la raíz del proyecto.
- La posibilidad de iniciar un reinicio manual mediante
SIGUSR2. - La salida coloreada y el amigable mensaje “presione R para reiniciar”.
node_modules, dist y .test.ts.Qué estaba sucediendo. node --watch es el monitor de archivos integrado desde hace tiempo en Node. Ahora funciona con archivos .ts gracias a la eliminación de tipos, pero nunca fue diseñado para ser un sustituto completo de herramientas especializadas como tsx watch o nodemon; se trata más bien de una función básica.
La solución. Utiliza node --watch cuando solo necesites un reinicio forzado ante cualquier cambio en un script individual. Para un servidor real con una cadena de importaciones y un ciclo de desarrollo o pruebas real, opta por tsx watch. No hay nada de malo en seguir utilizando tsx como herramienta de desarrollo incluso después de pasar la ejecución en producción al TypeScript nativo.
// package.json ; pragmatic split
{
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "node --enable-source-maps src/server.ts",
"start:native": "node src/server.ts"
}
}
tsx funciona con esbuild y es drásticamente más rápido que tsc — aproximadamente de 20 a 30 veces más rápido según las pruebas propias del proyecto — comprende los alias de rutas de forma nativa, y se comporta como lo haría node --watch si el modo de vigilancia hubiera recibido más atención en el desarrollo del producto.
7. Saltarse el paso de compilación solo reubicó el problema
El síntoma. Después de anunciar que el equipo dejaría de usar tsc en favor de ejecutar las aplicaciones de forma nativa, surgieron varios problemas casi de inmediato:
- El SDK publicado en npm necesitaba archivos de declaración
.d.tspara los usuarios finales. La ejecución nativa de TypeScript no genera dichos archivos. - El destino de despliegue en Lambda esperaba salida en formato CommonJS, pero el código estaba escrito en formato ESM.
.ts sin procesar, en lugar de los resultados compilados.Qué sucedió. La eliminación de código se realiza en tiempo de ejecución, no en el momento de la compilación; ese es precisamente el propósito de esta funcionalidad. Pero en cuanto tu código necesita ejecutarse en un entorno distinto a “Node 22 o superior, ejecutándose directamente desde tu repositorio”, vuelves a necesitar un paso de compilación. No desapareció; simplemente se trasladó a otra etapa del proceso.
La solución. Sé específico sobre qué tipo de contenido estás enviando realmente.
Si estás desarrollando una aplicación —un servicio que despliegas y ejecutas tú mismo—, TypeScript nativo representa una mejora real. No hay proceso de compilación, los arranques en frío son más rápidos y el Dockerfile se simplifica, ya que puedes simplemente ejecutar COPY src ./src en lugar de gestionar una carpeta dist/.
# Dockerfile
FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src ./src
COPY tsconfig.json ./
CMD ["node", "--enable-source-maps", "src/server.ts"]
Si estás manteniendo una biblioteca destinada a npm, conserva tsc para la fase de generación real del código. Puedes utilizar TypeScript nativo durante el desarrollo y las pruebas, pero el paquete publicado aún debe incluir archivos .js compilados junto con los archivos .d.ts.
// package.json ; library case
{
"scripts": {
"dev": "node --watch src/index.ts",
"build": "tsc",
"test": "node --test --experimental-strip-types test/*.test.ts"
}
}
Si su objetivo son entornos sin servidor, entornos de ejecución en el borde o consumidores en Node 20.x, aún necesita un transpilador — ya sea swc o tsc — configurado para generar código que los entornos más antiguos puedan ejecutar. El paso de compilación que pensó que había eliminado sigue siendo necesario allí.
¿Valió la pena? El veredicto honesto.
El soporte nativo para TypeScript podría ser la mejora más significativa en Node desde la llegada de async/await — pero ese elogio viene acompañado de una importante salvedad.
Tiene sentido adoptarlo si:
- Deploya un servicio autogestionado en Node 22.18+ o en la línea LTS 24.x.
- Ya escribe código en TypeScript limpio y fácil de eliminar — sin enums, sin decoradores, únicamente sintaxis ESM.
- Tiene un job de CI ejecutando
tsc --noEmitpara que la verificación de tipos no desaparezca silenciosamente de su flujo de trabajo.
node_modules.Es mejor seguir utilizando tsx o tsc si:
- Publica una biblioteca que debe ejecutarse en versiones antiguas de Node para tus usuarios.
- Dependes en gran medida de NestJS, TypeORM, class-validator u otras herramientas basadas en decoradores experimentales.
- Necesitas soporte para
.tsxen componentes React renderizados en el servidor. - Confías en los alias de ruta de
tsconfigy aún no estás listo para pasar al campoimports. - Todavía no tienes la disciplina (o las herramientas) para evitar que haya sintaxis imposible de eliminar en tu código.
La configuración que finalmente lo hizo viable:
// tsconfig.json
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"moduleResolution": "nodenext",
"noEmit": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
// package.json (snippet)
{
"type": "module",
"engines": { "node": ">=22.18.0" },
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "node --enable-source-maps src/server.ts",
"typecheck": "tsc --noEmit",
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'",
"ci": "npm run typecheck && npm test"
}
}
Esa es toda la configuración. No hay rastro de ts-node. Tampoco se encuentra tsc en el camino de ejecución en tiempo real. No existe un archivo nodemon.json extenso. Una herramienta se encarga del desarrollo, otra de la verificación de tipos y otra más de la producción. Y para dejarlo claro, node_modules sigue siendo tan voluminoso como siempre; ese aspecto específico del ecosistema Node no ha cambiado desde 2009.
La verdadera ventaja aquí no es que ts-node desaparezca de tus dependencias. Es que dejas de creer que al eliminar ts-node también se elimina tu paso de compilación. La ejecución nativa de TypeScript es un proceso de compilación más pequeño, rápido y transparente, pero sigue siendo un proceso de compilación. Esta migración no implica pasar de “tener una compilación” a “no tener ninguna compilación”. Implica pasar de un paso de compilación que no podías ver a uno que realmente comprendes.
Lecturas relacionadas
- Qué hace y no hace en realidad el soporte nativo de TypeScript en Node.js — Este artículo explica cómo Node.js ejecuta archivos .ts de forma nativa mediante la eliminación de tipos, por qué omite la verificación de tipos y cuándo aún necesitas un verdadero paso de compilación.