Why did code that worked yesterday suddenly break?
Mixing import/require, or adding a single "type": "module" line to package.json, and watching the whole project drown in red stack traces is one of the most common situations reported in production. Especially as popular packages like chalk 5, node-fetch 3, execa, and nanoid went ESM-only, cases exploded where existing CommonJS code simply required them and immediately hit ERR_REQUIRE_ESM.
This is not a concepts lecture. It does not explain what ESM is or what CommonJS is. It only covers pinpointing the cause in 30 seconds and recovering immediately with copy-paste code when you hit these three sibling errors:
Cannot use import statement outside a moduleERR_REQUIRE_ESM(orError [ERR_REQUIRE_ESM]: require() of ES Module ...)require is not defined in ES module scope, you can use import instead
Scope: Node.js 18/20/22, TypeScript 5.x, ts-node 10+, Jest 29+, Vite 5 / Webpack 5. Start with the diagnostic chart below and jump straight to the section that matches your case.
30-second diagnostic chart — identify the cause from the error text alone
Find the exact error line you hit in the table. Cause and destination section map immediately.
| Error text | Most common cause | Fix section |
|---|---|---|
Cannot use import statement outside a module | Using import but the file is interpreted as CommonJS. type unset in package.json, .ts compiled as CJS, or extension is .js with no type | ①·② / TS → tooling section |
ERR_REQUIRE_ESM / require() of ES Module ... | CJS code requires an ESM-only package (chalk 5, node-fetch 3, etc.) | ④ |
require is not defined in ES module scope | Using require/module.exports/__dirname in a "type": "module" file | ③ |
Unknown file extension ".ts" (ts-node) | ts-node loading .ts in ESM mode without the loader configured | Tooling section (ts-node) |
SyntaxError: Cannot use import statement outside a module (Jest) | Jest failing to transform ESM/TS | Tooling section (Jest) |
One-sentence summary: "Is one side using import while the other is CJS, or is one side using require while the other is ESM?" Distinguish those two axes and you are done.
Cause-by-cause copy-paste runbook (plain Node)
① Clean up type — decide the project-wide mode
The first thing to check is the type field in package.json. That one line decides whether .js files are treated as ESM or CJS.
// package.json — unify the project as ESM (import/export)
{
"name": "my-app",
"type": "module"
}// package.json — lock the project to CommonJS (require/module.exports)
{
"name": "my-app",
"type": "commonjs" // or omit the type field entirely
}Behavior rules at a glance:
type value | .js interpretation | .mjs | .cjs |
|---|---|---|---|
"module" | ESM | ESM | CommonJS |
"commonjs" or omitted | CommonJS | ESM | CommonJS |
If you got Cannot use import statement outside a module → you have a .js file using import but type is missing or commonjs. If you want the project to go ESM, add "type": "module".
② Isolate a single file with .mjs / .cjs
If you do not want to touch the whole project, you can force just one file via extension.
// script.mjs — always ESM, regardless of type
import fs from 'node:fs';
export const hello = () => 'esm';// legacy.cjs — always CommonJS, regardless of type
const fs = require('node:fs');
module.exports = { hello: () => 'cjs' };.mjs is the safest way to add a single ESM script to a legacy project. Conversely, use .cjs when a "type": "module" project must keep an old CJS config file.
③ Replacing require / __dirname in ESM
require is not defined in ES module scope means you used CJS-only syntax inside an ESM file. ESM does not provide require, __dirname, or __filename by default. Drop in the snippets below as-is.
// When you really need require in ESM (loading a CJS package, etc.)
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const someCjsModule = require('some-legacy-cjs-pkg');// Restoring __dirname / __filename in ESM
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);Code that used require('./data.json') can switch to import attributes in ESM.
// Node 20.10+ / 22: JSON import (import attributes)
import data from './data.json' with { type: 'json' };④ Loading ESM-only packages from CommonJS — dynamic import()
The classic cause of ERR_REQUIRE_ESM is CJS code loading an ESM-only package with require('chalk'). Switch require to dynamic import().
// ❌ require of an ESM-only package from CommonJS → ERR_REQUIRE_ESM
const chalk = require('chalk'); // chalk 5 is ESM-only
// ✅ bypass with dynamic import() (works even in a CJS file)
async function main() {
const { default: chalk } = await import('chalk');
console.log(chalk.green('OK'));
}
main();If you cannot use top-level await in CJS, wrap it in an async function as above. This is the most realistic option when migrating the whole project to ESM is too heavy. If you want a root-cause workaround, downgrading to the last CJS version of the package (e.g. chalk@4, node-fetch@2) is also a common bypass.
Tool-specific traps (ts-node · Jest · bundlers)
TypeScript / ts-node
For TypeScript 5.x targeting Node, the NodeNext combo is the standard recommendation. This setup respects package.json type and file extensions as-is.
// tsconfig.json — modern Node ESM target (recommended)
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"esModuleInterop": true,
"outDir": "dist"
}
}// tsconfig.json — lock to legacy CommonJS
{
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node10", // or "Node"
"target": "ES2020",
"esModuleInterop": true
}
}module setting summary:
| Setting | Output shape | When |
|---|---|---|
NodeNext | ESM/CJS auto based on type | New Node projects |
CommonJS | Always transformed to require | Legacy keep, Jest CJS |
ESNext + Bundler moduleResolution | Bundler handles it | Vite/Webpack apps |
If running .ts directly with ts-node hits Cannot use import statement outside a module or Unknown file extension ".ts", you need to enable the ESM loader.
// add a ts-node block to tsconfig.json
{
"compilerOptions": { "module": "NodeNext", "moduleResolution": "NodeNext" },
"ts-node": { "esm": true }
}# run (Node 20+). If healthy, the script output prints as-is
node --loader ts-node/esm ./src/index.ts
# or
npx ts-node --esm ./src/index.tsThe expected healthy result is the script running with no error. If you still get Unknown file extension, check whether package.json has "type": "module" and whether you mixed .cts/.mts instead of .ts.
Jest
Jest ESM support is still experimental. Pick one of two forks.
Fork A — just revert to CommonJS (most stable). If you do not strictly need ESM, leaving ts-jest on CJS causes the least trouble.
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
// compiling tsconfig module as CommonJS usually fixes it
};Fork B — run as ESM. When you must keep ESM-only dependencies as-is.
// package.json — Jest ESM run script
{
"scripts": {
"test": "node --experimental-vm-modules node_modules/.bin/jest"
}
}// jest.config.js — ESM + ts-jest useESM
export default {
preset: 'ts-jest/presets/default-esm',
testEnvironment: 'node',
extensionsToTreatAsEsm: ['.ts'],
transform: {
'^.+\\.tsx?$': ['ts-jest', { useESM: true }],
},
};If Cannot use import statement outside a module only happens during Jest, it is usually a missing transform config. In practice, recover fast with fork A (CJS rollback), then migrate to B when you have time.
Vite / Webpack config file issues
In a "type": "module" project, config files like postcss.config.js or .eslintrc.js that use CJS syntax (module.exports) will break. Renaming just those config files to .cjs fixes it immediately.
# isolate CJS-syntax config files in an ESM project
mv postcss.config.js postcss.config.cjs
mv .eslintrc.js .eslintrc.cjs # or migrate to flat config (eslint.config.js) as ESMFor Vite app code itself, TypeScript 5.x recommends "moduleResolution": "Bundler" and "module": "ESNext" in tsconfig.
Rollback & decision tree
Minimal rollback when adding type: module broke everything
If you just added "type": "module" and the project collapsed, the fastest recovery is deleting that one line.
// package.json — restore original
{
"name": "my-app"
// "type": "module" ← delete this line (or set "commonjs")
}If you still must stay on ESM (because of ESM-only dependencies), migrate in this order:
- Replace
require→import,module.exports→exportacross the board - Replace
__dirname/__filenamewith thefileURLToPathsnippet from section ③ - Explicit extensions on local relative imports:
import x from './util.js'(ESM cannot omit extensions) - Isolate config files (
*.config.js) as.cjs - Unify tsconfig
module/moduleResolutiontoNodeNext
Safest choice by situation
Goal is keeping a legacy codebase?
├─ Yes → lock CommonJS (omit type / commonjs) + dynamic import() for ESM-only packages
│ + tsconfig module: CommonJS
└─ No (new/modern) → unify on ESM (type: module)
+ tsconfig module/moduleResolution: NodeNext
+ Vite/Webpack apps → moduleResolution: BundlerRecurrence-prevention checklist (5 lines)
- Before installing a new package, check the
READMEfor ESM-only (chalk 5+, node-fetch 3+, etc.) - Unify
package.jsontypeand tsconfigmodulein one direction - Cross the CJS↔ESM boundary only via dynamic
import() - In ESM, use
createRequire/import.meta.urlinstead ofrequire/__dirname - Isolate config files as
.cjswhen needed so they stay separate from app code
FAQ
Q. Why does requiring chalk throw ERR_REQUIRE_ESM?
A. From chalk 5 it is ESM-only and cannot be loaded with CommonJS require. Bypass with dynamic const { default: chalk } = await import('chalk'), or if you are on a CJS project, downgrade to chalk@4.
Q. In tsconfig, should I pick NodeNext or CommonJS?
A. For a new Node project, NodeNext (both module and moduleResolution) is the standard. If you must keep existing CJS code and a Jest CommonJS environment, CommonJS + Node10 causes less trouble. Bundler (Vite/Webpack) apps should use moduleResolution: "Bundler".
Q. After switching to ESM, relative-path imports do not work.
A. ESM does not allow omitting extensions. Change import x from './util' to import x from './util.js' (even in TS source, use the compiled output extension .js). moduleResolution: "NodeNext" enforces this rule.
Nodelog는 모든 콘텐츠의 내용과 출처를 공개 전에 검토합니다. 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서와 함께 확인하며, 검토 기준과 정정 원칙은 편집 정책에서 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.
Comments
Be the first to comment.