Usar TypeScript ESM no Node.js exige alinhar três sistemas: o formato de módulos do Node.js, a resolução do compilador TypeScript e a estrutura publicada no package.json. Quando essas camadas discordam, o editor pode aceitar um import que falha em runtime, o build pode gerar CommonJS sem intenção ou um pacote pode funcionar localmente e quebrar depois de publicado.
A configuração moderna para aplicações e bibliotecas executadas diretamente no Node.js usa "type": "module" e um modo de compilação que modela as regras reais do runtime, como NodeNext. Isso significa escrever imports relativos com a extensão que existirá no JavaScript emitido, respeitar exports, diferenciar arquivos ESM e CommonJS e testar o resultado compilado.
Neste guia, você aprenderá a configurar TypeScript ESM, entender NodeNext, usar extensões .ts, .mts e .cts, substituir __dirname, importar JSON, trabalhar com CommonJS, criar aliases válidos e preparar pacotes para npm.
Como o Node.js decide o formato?
O Node.js observa a extensão e o package.json mais próximo:
.mjsé sempre ESM;.cjsé sempre CommonJS;.jsé ESM quando o pacote contém"type": "module";.jsé CommonJS quandotypeestá ausente ou definido comocommonjs.
O TypeScript aplica regras equivalentes aos arquivos-fonte:
.mtssempre representa ESM e gera.mjs;.ctssempre representa CommonJS e gera.cjs;.tssegue o campotypedo pacote.
A referência oficial de módulos do TypeScript explica que os modos node16, node18, node20 e nodenext modelam o sistema dual do Node.js.
package.json para ESM
{
"name": "minha-api",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc --noEmit",
"start": "node dist/server.js"
}
}O campo type também influencia ferramentas, testes e arquivos JavaScript do projeto. Use .cjs para um arquivo específico que ainda precise ser CommonJS.
tsconfig com NodeNext
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"sourceMap": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}module: NodeNext implica a resolução correspondente e acompanha o comportamento das versões atuais do Node.js. Para uma versão fixa de runtime, um modo como Node20 pode oferecer semântica mais estável quando disponível na versão do TypeScript usada.
Não use ESNext com Node.js direto
module: ESNext com moduleResolution: bundler é adequado para bundlers e runtimes que aceitam imports sem extensão. Para código emitido por tsc e executado diretamente pelo Node.js, use um modo Node.
O compilador precisa reproduzir as mesmas decisões do runtime. Caso contrário, um caminho pode passar no typecheck e falhar com ERR_MODULE_NOT_FOUND.
Imports relativos precisam de .js
No arquivo TypeScript:
import { createServer } from './server.js';
import { config } from './config.js';Mesmo que os arquivos-fonte sejam server.ts e config.ts, o import aponta para .js, porque essa é a extensão que existirá em dist. O TypeScript faz a substituição de extensão durante a resolução.
Evite:
import { createServer } from './server';
import { config } from './config.ts';O primeiro caminho não é válido em ESM nativo do Node.js. O segundo exige opções especiais e normalmente não corresponde ao JavaScript emitido.
Imports de pacotes
Pacotes continuam sem extensão:
import Fastify from 'fastify';
import { z } from 'zod';A resolução passa pelo node_modules e pelos campos exports, types e condições do pacote.
Imports apenas de tipos
import { createOrder, type CreateOrderInput } from './orders.js';
import type { FastifyInstance } from 'fastify';import type é removido do JavaScript. Com verbatimModuleSyntax, imports e exports de valor permanecem como escritos, reduzindo transformações inesperadas.
Extensões .mts e .cts
Use .mts quando um arquivo deve ser ESM independentemente do pacote:
src/cli.mts → dist/cli.mjsUse .cts para CommonJS:
src/legacy.cjs.cts → dist/legacy.cjs.cjsNa prática, prefira nomes simples, como legacy.cts, que gera legacy.cjs. Essas extensões são úteis em pacotes dual-format e arquivos de configuração específicos.
import.meta.url
ESM não possui __filename e __dirname globais. Use:
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const filename = fileURLToPath(import.meta.url);
const directory = dirname(filename);
const templatePath = join(directory, 'templates', 'email.html');Em versões atuais do Node.js, propriedades convenientes como import.meta.dirname e import.meta.filename podem estar disponíveis. Confirme o runtime mínimo antes de adotá-las.
createRequire em ESM
Quando uma biblioteca só pode ser carregada por require:
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const legacyConfig = require('./legacy-config.cjs');Use como ponte temporária. Para compreender as diferenças, consulte CommonJS no Node.js e ES Modules no Node.js.
Importando CommonJS em ESM
O module.exports de um pacote CommonJS geralmente aparece como export default:
import legacyPackage from 'legacy-package';Named imports podem funcionar quando o Node.js consegue detectá-los por análise estática, mas não são garantidos para todos os pacotes. Se houver dúvida, use default ou namespace e confirme em runtime.
Importando ESM de CommonJS
O caminho universal é import() dinâmico:
async function loadModule() {
const module = await import('./modern-module.js');
return module.default;
}Versões atuais do Node.js suportam alguns casos de require(ESM), mas módulos com top-level await continuam impondo restrições. Para bibliotecas amplamente distribuídas, teste todas as versões declaradas.
JSON modules
A sintaxe de importação de JSON evoluiu entre versões do Node.js. Em runtimes atuais, pode ser necessário informar o tipo:
import packageInfo from '../package.json' with { type: 'json' };Use o modo TypeScript que representa o runtime e habilite a opção apropriada quando necessário:
{
"compilerOptions": {
"resolveJsonModule": true,
"module": "NodeNext"
}
}Para compatibilidade ampla, ler JSON com fs.readFile continua sendo uma alternativa explícita.
Top-level await
const config = await loadConfig();
const server = await createServer(config);
await server.listen({ port: 3000 });Top-level await simplifica bootstrap ESM, mas pode atrasar toda a cadeia de importação. Não use para chamadas remotas escondidas em módulos de biblioteca.
package.json imports
Para aliases internos reconhecidos pelo Node.js:
{
"imports": {
"#config": "./dist/config.js",
"#domain/*": "./dist/domain/*.js"
}
}import config from '#config';
import { Order } from '#domain/order.js';Isso é preferível a paths isolado, porque funciona também em runtime. Veja Exports e Imports no package.json.
Por que paths pode quebrar?
{
"compilerOptions": {
"paths": {
"@app/*": ["./src/*"]
}
}
}paths altera apenas a resolução do TypeScript; o JavaScript emitido mantém @app/.... O Node.js não conhece esse alias sem loader, bundler ou imports. Para bibliotecas publicadas, não use paths para simular pacotes do monorepo.
Workspaces
Pacotes irmãos devem ser ligados por workspaces e importados pelo nome real:
import { Order } from '@empresa/domain';Assim, TypeScript e Node.js usam resolução real de pacote. Consulte npm Workspaces no Node.js.
Build para aplicação
{
"scripts": {
"clean": "node scripts/clean.js",
"build": "npm run clean && tsc -p tsconfig.build.json",
"typecheck": "tsc --noEmit",
"start": "node --enable-source-maps dist/server.js"
}
}Execute o JavaScript compilado no CI. Typecheck isolado não detecta todos os problemas de empacotamento e paths.
Source maps
{
"compilerOptions": {
"sourceMap": true,
"inlineSources": true
}
}Com node --enable-source-maps, stacks apontam para arquivos TypeScript. Proteja source maps quando contêm código que não deve ser exposto.
Bibliotecas ESM
Um pacote ESM tipado:
{
"name": "@empresa/domain",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": ["dist"]
}Teste o tarball com npm pack. Consulte Publicar Pacote no npm.
Pacotes dual-format
Publicar ESM e CommonJS exige dois outputs e declarations coerentes:
{
"exports": {
".": {
"import": {
"types": "./dist/esm/index.d.mts",
"default": "./dist/esm/index.mjs"
},
"require": {
"types": "./dist/cjs/index.d.cts",
"default": "./dist/cjs/index.cjs"
}
}
}
}Esse modelo aumenta testes, build e risco do dual package hazard. Publique dois formatos somente quando consumidores realmente precisam.
Testes com Node Test Runner
Após compilar:
node --test dist/**/*.test.jsDurante desenvolvimento, uma ferramenta como tsx pode executar TypeScript diretamente; o próximo artigo trata desse fluxo. Mantenha pelo menos uma etapa que teste o build real.
Jest e ferramentas antigas
Ferramentas historicamente centradas em CommonJS podem exigir transformadores, flags ou configuração experimental. Evite misturar soluções antigas sem necessidade. Vitest e o test runner nativo costumam ter integração mais simples com ESM moderno.
Migração de CommonJS
- Adicione typecheck e testes.
- Troque
module.exportsporexport. - Troque
requireporimport. - Adicione extensões
.jsaos imports relativos. - Substitua
__dirname. - Revise JSON e addons.
- Configure
type: module. - Use
NodeNext. - Compile e execute
dist. - Migre arquivos incompatíveis para
.ctstemporariamente.
CI
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
- run: npm run build
- run: node --enable-source-maps dist/smoke-test.jsVeja CI para Node.js com GitHub Actions.
Erros comuns
- Import sem .js: runtime não resolve.
- module ESNext: TypeScript não modela Node.js.
- paths sem runtime: build quebra.
- __dirname em ESM: variável não existe.
- Named import de CommonJS: pode falhar.
- JSON sem atributo: sintaxe incompatível.
- Teste só com executor TS: dist não é validado.
- Dual package sem testes: instâncias e tipos divergem.
Configuração recomendada
// package.json
{
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.build.json",
"start": "node --enable-source-maps dist/server.js"
}
}// tsconfig.build.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"declaration": true,
"sourceMap": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"]
}Conclusão
Usar TypeScript ESM no Node.js funciona bem quando o compilador imita o runtime. type: module, NodeNext e extensões .js nos imports relativos eliminam grande parte das inconsistências.
Use imports para aliases, exports explícitos para bibliotecas, source maps para diagnóstico e testes sobre o JavaScript compilado. Com essas práticas, TypeScript oferece tipos sem esconder as regras reais do sistema de módulos do Node.js.




