Next.js

Instalé TypeScript 7 y ESLint dejó de arrancar: no hay versión que lo arregle

Autorangel cruz
Publicado
Lectura6 min de lectura
Instalé TypeScript 7 y ESLint dejó de arrancar: no hay versión que lo arregle

El typecheck bajó de 3,15 segundos a 0,69. El build de Next pasó de 3,3 segundos a 378 milisegundos typechequeando. Tres de mis cuatro gates de CI en verde y más rápidos que nunca.

El cuarto no arranca, y no hay nada que instalar para arreglarlo.

Cómo llegué aquí

Next.js 16.3 salió recomendando TypeScript 7, que es el port nativo del compilador. Sus propios docs son escuetos sobre lo que hay que hacer:

TypeScript 7 does not currently provide the JavaScript compiler API. To use TypeScript 7 during next build, install it in your project.

pnpm add -D typescript@^7

Eso es todo. Next usa el CLI local de tsc por defecto, así que no hay configuración extra. Lo instalé, corrí los gates y salió esto:

typecheck  ✓  0.69s
test       ✓  104 passed
build      ✓  TypeScript en 378ms
lint       ✗

El error dice exactamente lo que pasa

typescript-eslint does not support TS 7.0.
Please see .../#running-side-by-side-with-typescript-6.0 to run
typescript-eslint using the TS 6 API.
See also .../issues/10940 for tracking typescript-eslint's
support for TS >=7.1

Fíjate en la frase de los docs de Next otra vez, porque es la causa entera: no expone API JavaScript. TypeScript 7 hoy existe solo como binario de línea de comandos.

Y un linter de TypeScript no puede trabajar con un binario. Necesita entrar al compilador por código: recorrer el AST, preguntar el tipo de una expresión, resolver un símbolo. Eso es la API JavaScript, y en la 7 todavía no está. La API estable programática está planificada para 7.1, no para 7.0.

Lo importante es que esto no afecta solo al linter. Afecta a todo lo que lee tipos programáticamente, y hay issues abiertos por el mismo motivo aguas arriba, como el de ts-api-utils, la librería sobre la que typescript-eslint construye sus checks de tipos.

No es un rango de peer desactualizado

Mi primera reacción fue la obvia: el peer estará mal puesto, instalo una versión nueva y listo. No.

$ npm view typescript-eslint@8.66.0 peerDependencies
{ "typescript": ">=4.8.4 <6.1.0" }
 
$ npm view typescript-eslint@8.66.1-alpha.0 peerDependencies
{ "typescript": ">=4.8.4 <6.1.0" }

Ni latest ni canary mueven ese tope. Y el rechazo no vive en el paquete que importas, vive en el core compartido. Lo comprobé cargando cada entrada por separado:

$ node -e "import('@typescript-eslint/parser')"
FAIL: typescript-eslint does not support TS 7.0.
 
$ node -e "import('@typescript-eslint/eslint-plugin')"
FAIL: typescript-eslint does not support TS 7.0.

El issue que pide soporte para 7.0.2 está cerrado como not planned. El de adoptar el compilador nativo sigue abierto, bloqueado por algo que no depende de typescript-eslint: ESLint no soporta parsers asíncronos, y el compilador nativo se consume por binding nativo o WASM, que es asíncrono.

O sea que no hay fecha. No es "espera al martes".

Los cinco intentos con pnpm

Si TypeScript 7 tiene que estar en la raíz y ESLint necesita un 6, la idea evidente es darle un 6 solo a ESLint. pnpm tiene mecanismos para eso. Probé todos y anoto por qué fallan, porque el patrón es el mismo y explica bastante.

1. packageExtensions con dependencies. Inyectar un typescript real dentro de cada paquete de typescript-eslint, apuntando al shim de la 6:

packageExtensions:
  "@typescript-eslint/typescript-estree":
    dependencies:
      typescript: npm:@typescript/typescript6@^6.0.2

El symlink acabó apuntando a la 7 igual:

$ readlink node_modules/.pnpm/typescript-eslint@.../node_modules/typescript
../../typescript@7.0.2/node_modules/typescript

2. packageExtensions con peerDependencies, para corregir el rango declarado. Tampoco cambia la resolución.

3. overrides por rango. Este casi funciona, y su forma de fallar es la más instructiva:

overrides:
  "typescript@>=4.8.4 <6.1.0": npm:@typescript/typescript6@^6.0.2

Alcanzó una instancia, pero no la que eslint-config-next empaqueta. Ese paquete declara typescript: >=3.3.1, un rango que acepta la 7, así que se llevaba el compilador de la raíz. Al añadir ese segundo rango al override desapareció TypeScript 7 del proyecto entero: el selector matchea por versión resuelta, no por el string declarado, y 7.0.2 satisface >=3.3.1. No hay forma de distinguir el peer laxo de la dependencia de la raíz.

4. overrides con padre>hijo, incluido el padre correcto:

overrides:
  "eslint-config-next>typescript": npm:@typescript/typescript6@^6.0.2

Nada.

5. Quitar eslint-config-next y declarar los seis plugins directos, para que exista una sola copia de typescript-eslint y el override la alcance. Al ser dependencia directa de la raíz, su peer se resolvió a la 7 de la raíz. Peor punto de partida que el inicial.

La razón única detrás de los cinco: los peer dependencies se resuelven desde quien importa, no desde quien los declara. Los overrides y las packageExtensions actúan sobre dependencias normales. Un peer siempre acaba mirando al importador, y el importador aquí es la raíz, donde vive TypeScript 7. Ninguna capa de configuración de pnpm se interpone ahí.

Las dos salidas que quedan

Ni una es bonita.

La primera es el shim oficial. Microsoft publica @typescript/typescript6, que reexporta la API de la 6, y documenta instalarlo lado a lado:

{
  "devDependencies": {
    "@typescript/native": "npm:typescript@^7.0.2",
    "typescript": "npm:@typescript/typescript6@^6.0.2"
  }
}

El nombre typescript pasa a ser el shim, que es lo que ESLint necesita, y la 7 entra bajo otro nombre aportando el binario tsc. Funciona: los cuatro gates en verde, y la 7 sigue typechequeando todo.

El precio es que tu package.json deja de decir la verdad de un vistazo. Alguien que lo abra lee typescript: @typescript/typescript6 y concluye que el proyecto está en la 6. Y en Next hay un detalle extra: su checker busca bin.tsc dentro del paquete typescript, y el shim expone tsc6, así que el build no arranca sin apagar el checker interno y encadenar tsc --noEmit en el script.

La segunda es aceptar que no hay linter. TypeScript 7 pelado, package.json honesto, y el gate de lint fuera hasta que upstream se ponga al día.

Elegí esta. El proyecto es un blog estático con typecheck estricto y 104 tests, y el typecheck es el que caza lo que de verdad rompe producción. Perder las reglas de @typescript-eslint durante unas semanas duele menos que dejar en el repo un package.json que miente sobre su propia versión de TypeScript. Lo dejé escrito en el workflow, porque un paso ausente sin explicación se lee como un olvido:

# FALTA EL PASO DE LINT, y no es un olvido: el proyecto usa TypeScript 7,
# que todavía no expone API JavaScript (solo CLI). typescript-eslint
# depende de esa API, así que aborta al ver un 7.x y ESLint no puede ni
# parsear los .ts.

Conclusión

La pregunta útil antes de subir a TypeScript 7 no es si tu código compila. Compila. Es qué herramientas de tu stack leen tipos por código, porque esas son las que se caen: linters, transformers de AST, cualquier cosa construida sobre la API del compilador.

Si tu respuesta es "solo tsc", sube hoy y disfruta los 4x. Si tienes un linter con reglas type-aware, y casi cualquier proyecto TypeScript serio lo tiene, la decisión no es técnica: es si prefieres un package.json raro o un gate de calidad menos, hasta que llegue la 7.1.

Que es, por cierto, un buen recordatorio de que "10 veces más rápido" nunca es el coste total de una migración.

Si quieres seguir el hilo: el anuncio de TypeScript 7.0, los docs de Next sobre TypeScript, y los issues typescript-eslint#10940 y #12518.