TypeScript e módulos ESM no Node.js
1. Introdução ao ESM no Node.js com TypeScript
Os módulos ECMAScript (ESM) representam o sistema de módulos oficial da linguagem JavaScript, padronizado pelo ECMAScript 2015 (ES6). Diferentemente do CommonJS (CJS), que utiliza require() e module.exports, o ESM emprega as palavras-chave import e export, alinhando-se ao padrão utilizado em navegadores e no ecossistema front-end moderno.
A adoção de ESM com TypeScript no Node.js traz benefícios significativos: suporte nativo a top-level await, análise estática de dependências que permite tree-shaking mais eficiente, e compatibilidade com o crescente número de pacotes no ecossistema que já migraram para ESM. No entanto, essa transição apresenta desafios específicos para desenvolvedores TypeScript, como a necessidade de configurar corretamente o compilador, lidar com extensões de arquivo em imports, e gerenciar a interoperabilidade com código CommonJS existente.
2. Configurando o tsconfig.json para ESM
A configuração correta do tsconfig.json é o primeiro passo para habilitar ESM com TypeScript. Duas opções principais para "module" são relevantes:
// tsconfig.json
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"target": "ES2022",
"strict": true,
"esModuleInterop": true,
"sourceMap": true
},
"include": ["src/**/*"]
}
A opção "module": "NodeNext" é a recomendada atualmente, pois instrui o TypeScript a gerar código ESM e respeitar as regras de resolução de módulos do Node.js. Alternativamente, "module": "ESNext" também funciona, mas não oferece o mesmo nível de integração com as especificidades do Node.js.
"moduleResolution": "NodeNext" é obrigatório para que o TypeScript resolva imports da mesma forma que o Node.js faz em modo ESM, considerando extensões e caminhos corretamente. A configuração de "outDir" e "rootDir" garante que a estrutura de diretórios do código fonte seja preservada na saída compilada.
3. Extensões de arquivo e importações explícitas
Um dos aspectos mais contra-intuitivos para quem migra do CommonJS é a obrigatoriedade de incluir extensões .js nos imports, mesmo quando o arquivo fonte é .ts:
// src/utils/helper.ts
export function formatDate(date: Date): string {
return date.toISOString();
}
// src/index.ts - Importando com extensão .js
import { formatDate } from './utils/helper.js';
console.log(formatDate(new Date()));
Isso ocorre porque o TypeScript mantém as extensões originais nos imports durante a compilação. O Node.js, ao executar o código compilado, espera encontrar arquivos .js, não .ts. Para desenvolvimento, é possível usar "allowImportingTsExtensions": true no tsconfig.json, mas isso requer também "noEmit": true, sendo útil apenas para ferramentas que executam TypeScript diretamente.
Estratégias para evitar erros comuns incluem:
- Usar caminhos relativos completos sempre
- Configurar aliases de caminho com "paths" no tsconfig.json e complementar com tsconfig-paths em runtime
- Utilizar ferramentas como tsx que abstraem essa complexidade
4. Lidando com type: "module" no package.json
Para que o Node.js trate seu projeto como ESM, é necessário adicionar "type": "module" ao package.json:
// package.json
{
"name": "meu-projeto-esm",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"typescript": "^5.4.0"
}
}
Projetos puramente ESM adotam essa configuração globalmente. Para projetos híbridos que precisam coexistir com CommonJS, é possível nomear arquivos com extensão .cjs para código CommonJS e .mjs para ESM, independentemente da configuração de "type" no package.json.
Dependências que ainda usam CommonJS geralmente funcionam com ESM através do mecanismo de interoperabilidade do Node.js, mas podem exigir configurações adicionais como "esModuleInterop": true no TypeScript.
5. Interoperabilidade entre ESM e CommonJS
Importar módulos CommonJS de dentro de código ESM é suportado nativamente:
// Importando um módulo CJS de dentro de ESM
import chalk from 'chalk'; // Funciona para pacotes CJS com export default
// Para módulos CJS sem export default, use import padrão:
import pkg from 'cjs-package';
const { metodo } = pkg;
Para criar pacotes que funcionem tanto em ESM quanto em CJS, utilize exports condicionais no package.json:
// package.json
{
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs"
}
}
}
Em contexto ESM, __dirname e __filename não estão disponíveis. Substitua-os por:
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
6. Compilação e execução do código ESM com TypeScript
O fluxo básico de compilação e execução utiliza tsc seguido de node:
# Compilar TypeScript para JavaScript
npx tsc
# Executar o código compilado
node dist/index.js
Para uma experiência mais fluida durante o desenvolvimento, ferramentas modernas como tsx oferecem execução direta de TypeScript com suporte nativo a ESM:
# Instalação
npm install -D tsx
# Execução direta
npx tsx src/index.ts
O ts-node também suporta ESM, mas requer configuração adicional:
# Com ts-node e ESM
node --loader ts-node/esm src/index.ts
Para debugging com source maps, certifique-se de que "sourceMap": true esteja configurado no tsconfig.json e utilize o inspector do Node.js:
node --inspect-brk dist/index.js
7. Boas práticas e armadilhas comuns
Evitando importações circulares: O ESM detecta ciclos estaticamente, mas ainda podem ocorrer problemas. Projete sua arquitetura com dependências unidirecionais e utilize injeção de dependência quando necessário.
Gerenciamento de monorepos: Em projetos com múltiplos pacotes, configure "exports" no package.json de cada pacote para expor apenas a API pública:
// packages/utils/package.json
{
"exports": {
".": "./dist/index.js",
"./helpers": "./dist/helpers/index.js"
}
}
Testes com frameworks modernos: Vitest oferece suporte nativo a ESM e TypeScript:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node'
}
});
Para Jest, utilize ts-jest com configuração ESM:
// jest.config.ts
export default {
preset: 'ts-jest/presets/default-esm',
testEnvironment: 'node',
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1'
}
};
O Node test runner nativo (Node.js 20+) também funciona bem com ESM e TypeScript quando compilado previamente.
Armadilha comum: Esquecer de adicionar a extensão .js nos imports é a fonte mais frequente de erros. Sempre verifique se seus imports relativos incluem a extensão correta após a compilação.
Referências
- Documentação oficial do TypeScript: Modules — Guia completo sobre o sistema de módulos do TypeScript, incluindo configuração para ESM
- Node.js Documentation: ECMAScript Modules — Documentação oficial do Node.js sobre módulos ESM, interoperabilidade e resolução de módulos
- TypeScript 5.x: module and moduleResolution — Referência detalhada das opções de configuração
moduleemoduleResolutionno tsconfig.json - Migrating from CommonJS to ESM with TypeScript — Artigo técnico abordando estratégias práticas de migração e armadilhas comuns
- tsx: TypeScript Execute — Repositório oficial da ferramenta tsx para execução direta de TypeScript ESM
- Vitest: Testing Framework with ESM Support — Guia de configuração do Vitest para testes em projetos TypeScript com módulos ESM