ESM ou CommonJS no Node: import, require e o campo type
Quando usar import e quando usar require no Node, o que o type module muda no package.json e como sair dos erros de misturar os dois.
O Node oferece dois sistemas de módulos: CommonJS, com require e
module.exports, e ECMAScript Modules (ESM), com import e export. Os
dois rodam no mesmo runtime, mas cada arquivo é classificado por sua extensão e
pelo campo type do package.json.
Pense em duas normas de tomada no mesmo prédio. As duas fornecem energia, mas o
plugue e as regras de ligação são diferentes. .cjs identifica CommonJS,
.mjs identifica ESM e "type": "module" funciona como a placa que define o
padrão para os arquivos .js daquele pacote. Misturar a sintaxe crua sem um
adaptador produz erro antes de o módulo entregar seus exports.
No comportamento técnico, CommonJS carrega com require de forma síncrona e
expõe module.exports; ESM liga imports e exports segundo o padrão da linguagem
e analisa as dependências antes da execução do corpo.
Todos os exemplos abaixo são da API da livraria Página Viva: catálogo, estoque e frete. Cada saída foi copiada de uma execução real no Node 24.16.0 — e a versão importa bastante aqui, porque o Node 24 aceita coisas que o Node 16 recusava.
Dois sistemas de módulo dentro do mesmo Node
CommonJS nasceu junto com o Node, em 2009, quando o JavaScript ainda não tinha
sistema de módulos nenhum. Era uma invenção do Node para resolver um problema
que a linguagem não resolvia. Em 2015 a linguagem resolveu: import e export
entraram na especificação e viraram o padrão do navegador — o mesmo que você viu
em módulos import e export no JavaScript.
Sobrou para o Node a parte difícil: adotar o padrão novo sem quebrar o milhão de pacotes escritos no formato antigo. A convivência dos dois é o resultado disso. Começando pelo formato antigo, que ainda é o que você mais encontra:
const livros = [
{ isbn: '9788535914849', titulo: 'Grande Sertão: Veredas', preco: 89.9, qtd: 4 },
{ isbn: '9788573265484', titulo: 'O Cortiço', preco: 34.5, qtd: 0 },
{ isbn: '9788525056009', titulo: 'Torto Arado', preco: 59.9, qtd: 12 },
];
function disponivel(isbn) {
const livro = livros.find((l) => l.isbn === isbn);
return Boolean(livro && livro.qtd > 0);
}
module.exports = { livros, disponivel };const { livros, disponivel } = require('./estoque.js');
console.log('livros no catálogo:', livros.length);
console.log('Torto Arado disponível?', disponivel('9788525056009'));
console.log('O Cortiço disponível? ', disponivel('9788573265484'));require: síncrono, cacheado e resolvido em tempo de execução
require não é palavra reservada: é uma função que o Node injeta em todo
arquivo CommonJS. Ela roda quando a linha dela roda, lê o arquivo do disco na
hora e devolve o que estiver em module.exports naquele momento.
Como é uma função comum, o resultado dela é guardado em cache. O segundo
require do mesmo arquivo não executa o arquivo de novo — devolve o mesmo
objeto:
console.log(' [conexao.js] abrindo conexão com o banco');
module.exports = { aberta: true, desde: '2026-05-13' };console.log('linha 1 do cache.js');
const a = require('./conexao.js');
const b = require('./conexao.js');
console.log('mesma instância?', a === b);
console.log('arquivos no cache:', Object.keys(require.cache).length);Duas coisas ficam visíveis nessa saída. A primeira: linha 1 do cache.js foi
impressa antes do log de dentro do conexao.js. O require só aconteceu
quando o programa chegou nele. A segunda: a mensagem de abertura de conexão
aparece uma vez só, e a === b é true. É esse cache que faz um pool de banco
ou um cliente de API virar singleton sem você pedir.
Como o require é resolvido durante a execução, ele aceita ser condicional:
const destino = process.argv[2] ?? 'nacional';
const calcularFrete =
destino === 'internacional'
? require('./frete-internacional.js')
: require('./frete-nacional.js');
console.log(destino, '→ R$', calcularFrete(1.2).toFixed(2));Rodando com o argumento internacional, o outro arquivo é que carrega — e o
primeiro nunca é lido:
import: estático, analisado antes da primeira linha rodar
O import é outra coisa. Ele não é função, é declaração: o Node lê o arquivo
inteiro, monta o grafo de dependências e executa todos os módulos importados
antes de rodar a sua primeira instrução. Dá para ver isso invertendo a ordem
de propósito, num projeto com "type": "module" no package.json:
console.log(' [estoque.js] carregado');
export const livros = [
{ isbn: '9788535914849', titulo: 'Grande Sertão: Veredas', preco: 89.9, qtd: 4 },
{ isbn: '9788573265484', titulo: 'O Cortiço', preco: 34.5, qtd: 0 },
{ isbn: '9788525056009', titulo: 'Torto Arado', preco: 59.9, qtd: 12 },
];
export function disponivel(isbn) {
const livro = livros.find((l) => l.isbn === isbn);
return Boolean(livro && livro.qtd > 0);
}console.log('linha 1 do index.js');
import { livros, disponivel } from './estoque.js';
console.log('livros no catálogo:', livros.length);
console.log('O Cortiço disponível?', disponivel('9788573265484'));O console.log do estoque saiu primeiro, mesmo estando abaixo. O import foi
içado. Isso é ótimo para ferramenta — um empacotador consegue saber o que você
usa sem executar nada —, mas cobra um preço: import não pode ficar dentro de
um if.
const destino = process.argv[2] ?? 'nacional';
if (destino === 'internacional') {
import calcularFrete from './frete-internacional.js';
}SyntaxError: Unexpected identifier ‘calcularFrete’ at compileSourceTextModule (node:internal/modules/esm/utils:318:16) at ModuleLoader.moduleStrategy (node:internal/modules/esm/translators:90:18) at #translate (node:internal/modules/esm/loader:435:20) Node.js v24.16.0
Quem carrega sob condição é o import() dinâmico, que é função, devolve promessa
e pode aparecer em qualquer lugar:
const destino = process.argv[2] ?? 'nacional';
const modulo =
destino === 'internacional'
? await import('./frete-internacional.js')
: await import('./frete-nacional.js');
console.log(destino, '→ R$', modulo.default(1.2).toFixed(2));Repare no await solto no topo do arquivo, sem função em volta. Isso é
top-level await, e só existe em ESM — guarde essa informação, porque ela volta
mais para a frente com um erro no colo.
import { livros } from './estoque';
console.log(livros.length);As outras causas dessa mesma família estão em Cannot find module no Node.
O campo type e as extensões .mjs e .cjs
Antes de executar qualquer arquivo, o Node decide de que tipo ele é. A regra é curta e vale a pena decorar:
A linha 2 do diagrama é a mais útil na prática: a extensão ganha do
package.json. Num projeto declarado como ESM, um arquivo .cjs continua sendo
CommonJS e continua tendo require:
const { origem, calcular } = require('frete-correios');
console.log('extensão .cjs vence o "type": "module" do package.json');
console.log('resolveu para:', origem, '| PAC 1,2 kg: R$', calcular(1.2).toFixed(2));Essa é a porta de saída da migração: você troca o projeto inteiro para ESM e vai
renomeando para .cjs os poucos arquivos que ainda não deu para converter.
O caso do package.json sem campo type
Aqui mora a mudança mais recente e a que mais confunde quem aprendeu com
material de 2021. Num projeto sem type no package.json, um arquivo .js
escrito com import funciona no Node 24 — o Node tenta ler como CommonJS,
percebe a sintaxe de módulo e reanalisa o arquivo como ESM:
import { livros } from './estoque.js';
console.log('títulos:', livros.map((l) => l.titulo).join(', '));Sobre o “performance overhead” que o aviso cita: medi as duas formas do mesmo
arquivo, 30 execuções por rodada, 3 rodadas, num MacBook com Node 24.16.0. Com
detecção de sintaxe deu 38,2 / 39,6 / 46,4 ms por execução; com o arquivo já
declarado .mjs, 39,0 / 37,3 / 42,9 ms. Ou seja: num arquivo pequeno o custo
some dentro do ruído da medição. O motivo de declarar o type não é velocidade
— é você e o seu editor pararem de adivinhar o que aquele arquivo é.
Os dois erros de sintaxe que sobraram
O Cannot use import statement outside a module não sumiu: ele aparece quando o
arquivo é declaradamente CommonJS. Basta o package.json dizer isso:
{
"name": "paginaviva-legado",
"version": "1.0.0",
"type": "commonjs"
}import { readFileSync } from 'node:fs';
console.log(readFileSync('./package.json', 'utf8').length, 'bytes lidos');SyntaxError: Cannot use import statement outside a module at wrapSafe (node:internal/modules/cjs/loader:1787:18) at Module._compile (node:internal/modules/cjs/loader:1828:20) at Object..js (node:internal/modules/cjs/loader:1985:10) Node.js v24.16.0
O irmão dele acontece no mesmo lugar, quando é o export que está do lado
errado — num arquivo .cjs, por exemplo:
export const TAXA_CARTAO = 0.0399;SyntaxError: Unexpected token ‘export’ at wrapSafe (node:internal/modules/cjs/loader:1787:18) at Module._compile (node:internal/modules/cjs/loader:1828:20) at Object..js (node:internal/modules/cjs/loader:1985:10) Node.js v24.16.0
Os dois querem dizer a mesma coisa: este arquivo é CommonJS e você escreveu
sintaxe de ESM nele. A correção é sempre uma das três — trocar a sintaxe,
renomear para .mjs ou pôr "type": "module" no
package.json.
Misturando os dois: o que o Node 24 já aceita e o que ainda não
ESM importando CommonJS sempre funcionou. O caminho contrário é que era proibido
— e essa é a parte que mudou. Hoje um arquivo CommonJS consegue dar require num
módulo ESM, desde que ele seja síncrono:
export const TAXA_CARTAO = 0.0399;
export function comJuros(valor) {
return valor * (1 + TAXA_CARTAO);
}const precos = require('./precos.mjs');
console.log('o que veio:', Object.keys(precos));
console.log('Torto Arado no cartão: R$', precos.comJuros(59.9).toFixed(2));Se você buscar esse assunto vai achar muito texto dizendo que isso lança
ERR_REQUIRE_ESM. Foi verdade por anos, e deixou de ser a partir do Node 22.12.
O que continua impossível é dar require num módulo que usa top-level await,
porque aí não existe resultado síncrono para devolver:
const dolar = await Promise.resolve(5.42);
export const cotacaoDolar = dolar;
export const emReais = (usd) => usd * dolar;const { emReais } = require('./cotacao.mjs');
console.log('livro importado: R$', emReais(19.9).toFixed(2));A mensagem já entrega a correção: import() dinâmico, que devolve promessa e
por isso pode esperar o await lá de dentro terminar.
async function main() {
const { emReais } = await import('./cotacao.mjs');
console.log('livro importado: R$', emReais(19.9).toFixed(2));
}
main();Falta o atrito na direção mais comum: ESM importando um arquivo CommonJS. Isso
funciona, mas os nomes nem sempre chegam. O Node lê o texto do arquivo
CommonJS procurando atribuições a exports para oferecer imports nomeados — e
quando o arquivo monta as exportações em tempo de execução, não tem o que
detectar:
const REGRAS = { PRIMEIRACOMPRA: 10, LEIAMAIS: 25 };
for (const codigo of Object.keys(REGRAS)) {
module.exports[codigo.toLowerCase()] = (valor) =>
valor - valor * (REGRAS[codigo] / 100);
}import { leiamais } from './cupons.cjs';
console.log('R$', leiamais(59.9).toFixed(2));import pkg from ‘./cupons.cjs’; const { leiamais } = pkg;
at #asyncInstantiate (node:internal/modules/esm/module_job:327:21)Node.js v24.16.0
O próprio erro escreve a correção: importe o default e desestruture depois. Aí o
module.exports inteiro chega como um objeto:
import cupons from './cupons.cjs';
console.log('exports detectados:', Object.keys(cupons));
console.log('Torto Arado com cupom LEIAMAIS: R$', cupons.leiamais(59.9).toFixed(2));É por isso que import express from 'express' funciona: o Express é CommonJS, e
o que você recebe no express é o module.exports dele.
__dirname, __filename e require não existem em ESM
Aquelas variáveis que todo tutorial de CommonJS usa não são do JavaScript: são do embrulho que o Node coloca em volta de arquivo CommonJS. Em ESM, o embrulho não existe:
console.log('__dirname:', __dirname);ReferenceError: __dirname is not defined in ES module scope This file is being treated as an ES module because it has a ‘.js’ file extension and ‘/private/tmp/paginaviva-esm/package.json’ contains “type”: “module”. To treat it as a CommonJS script, rename it to use the ‘.cjs’ file extension. at file:///private/tmp/paginaviva-esm/caminho.js:1:27 Node.js v24.16.0
O mesmo acontece com require dentro de um arquivo ESM: ReferenceError: require is not defined in ES module scope, you can use import instead. Repare
que a mensagem inteira, nos dois casos, explica por que o arquivo virou ESM
e o que renomear para desfazer. É o erro mais bem escrito do Node.
A substituição direta é import.meta, que carrega o caminho do próprio módulo:
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
console.log('import.meta.dirname :', import.meta.dirname);
console.log('import.meta.filename:', import.meta.filename);
const pkg = readFileSync(join(import.meta.dirname, 'package.json'), 'utf8');
console.log('name do package.json:', JSON.parse(pkg).name);Com isso na mão, ler e escrever arquivo no Node
segue igual ao que você já conhece. E quando um pacote antigo só funciona com
require, dá para fabricar um require local:
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const pkg = require('./package.json');
console.log('versão da lib:', pkg.version, '| tipo:', pkg.type);Os campos main e exports: como a sua lib é encontrada
Quando você escreve require('frete-correios') ou
import ... from 'frete-correios', o Node vai até o package.json do pacote
para descobrir qual arquivo abrir. O campo antigo é o main, um caminho só. O
campo moderno é o exports, que responde diferente para cada sistema de
módulo:
{
"name": "frete-correios",
"version": "2.0.0",
"exports": {
".": {
"import": "./esm/index.mjs",
"require": "./cjs/index.cjs"
},
"./tabela": "./esm/tabela.mjs"
}
}O mesmo nome de pacote, pedido dos dois lados, entrega dois arquivos:
const frete = require('frete-correios');
console.log('CommonJS recebeu:', frete.origem, '| R$', frete.calcular(1.2).toFixed(2));import { origem, calcular } from 'frete-correios';
console.log('ESM recebeu: ', origem, '| R$', calcular(1.2).toFixed(2));É assim que uma biblioteca publica os dois formatos sem quebrar ninguém. O
exports tem um segundo efeito, que costuma pegar de surpresa: ele é uma lista
fechada. Caminho que não está declarado ali deixa de ser importável, mesmo
existindo no disco:
import { margem } from 'frete-correios/esm/segredo.mjs';
console.log(margem);Com main, qualquer arquivo interno do pacote era importável e virava API
acidental. Com exports, a pessoa que mantém a biblioteca escolhe o que é
público — e é por isso que aquele import “que sempre funcionou” para de
funcionar depois de um npm update.
O que escolher em projeto novo, e como migrar um antigo
Em projeto novo, escreva "type": "module" no package.json na primeira hora e
não pense mais no assunto. É o padrão da linguagem, é o que roda no navegador, é
o que o TypeScript e os empacotadores esperam. A tabela abaixo é a decisão que eu
uso hoje:
| situação | escolha | por quê |
|---|---|---|
| API nova em Node 24 | ESM, "type": "module" |
padrão da linguagem, top-level await de graça |
| projeto legado grande, em produção | fica em CommonJS | migrar sem necessidade só produz risco |
| pacote que você publica no npm | ESM com exports dual |
quem consome escolhe o lado |
| script solto de uma vez só | .mjs, sem package.json |
roda em ESM sem configurar nada |
arquivo que precisa de require no meio de um projeto ESM |
renomeie para .cjs |
a extensão ganha do type |
Para migrar um projeto existente, a ordem que dá menos trabalho é esta. Primeiro, declare o tipo:
{
"name": "paginaviva",
"version": "1.0.0",
+ "type": "module",
"main": "index.js"
}Depois rode o projeto e siga os erros, que são poucos e sempre os mesmos: troque
require(...) por import ... from '...', acrescente a extensão nos caminhos
relativos, troque module.exports = x por export default x, e troque
__dirname por import.meta.dirname. O que resistir — geralmente um script
antigo ou uma dependência sem versão ESM — você renomeia para .cjs e resolve
depois. Um arquivo por vez, com o servidor subindo entre cada passo.
O próximo passo da trilha de Node é usar esse conhecimento para valer: ler e escrever arquivos, ler variáveis de ambiente e subir o primeiro servidor HTTP. Se ainda faltar o começo, o guia de Node mostra a ordem inteira, e o que é o Node.js explica de onde vem esse runtime que teve que abrigar dois sistemas de módulo ao mesmo tempo.
Monte dois diretórios mínimos: um com "type": "module" e outro sem o campo.
Exporte a mesma função com export no primeiro e module.exports no segundo,
importe e rode ambos. Em seguida troque apenas um arquivo para .cjs ou .mjs
e preveja qual regra vencerá. O exercício está completo quando você explica o
tipo de cada arquivo sem olhar para a sintaxe dentro dele.
Perguntas frequentes
Posso usar import e require no mesmo arquivo?
O TypeScript muda essa decisão?
Todo pacote do npm já publica em ESM?
E no navegador, muda alguma coisa?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em Node 24.16.0, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- Node.js — Modules: ECMAScript modules — nodejs.org
- Node.js — Modules: Packages — nodejs.org
- Node.js — Modules: CommonJS modules — nodejs.org


