Home > Blog > Desenvolvimento Web
Desenvolvimento Web
JavaScript
Programação

TypeScript ESM no Node.js

Atualizado em: 10 de setembro de 2026

Estação de trabalho usada no desenvolvimento de aplicações Node.js

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 quando type está ausente ou definido como commonjs.

O TypeScript aplica regras equivalentes aos arquivos-fonte:

  • .mts sempre representa ESM e gera .mjs;
  • .cts sempre representa CommonJS e gera .cjs;
  • .ts segue o campo type do 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.mjs

Use .cts para CommonJS:

src/legacy.cjs.cts → dist/legacy.cjs.cjs

Na 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.js

Durante 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

  1. Adicione typecheck e testes.
  2. Troque module.exports por export.
  3. Troque require por import.
  4. Adicione extensões .js aos imports relativos.
  5. Substitua __dirname.
  6. Revise JSON e addons.
  7. Configure type: module.
  8. Use NodeNext.
  9. Compile e execute dist.
  10. Migre arquivos incompatíveis para .cts temporariamente.

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.js

Veja 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.

10 melhores cursos de programação em 2026

Descubra os melhores cursos de programação. Aprenda a escolher o curso ideal para iniciar ou avançar na carreira de desenvolvedor

POSTS RELACIONADOS

Ver todos

Seta para a direita