TypeScript describes module in terms of the code it writes, but with noEmit nothing is written, and module still matters: it tells the checker which module system your files live in. That decides whether each .js file is an ES module or CommonJS, which syntax is allowed in it (top-level await, import.meta), and, unless you set moduleResolution yourself, how imports are found.
In a JavaScript project there are two good answers. "nodenext" follows Node.js: package.json "type", .mjs and .cjs, extensions on relative imports, exports maps. "preserve" with moduleResolution: "bundler" fits code that a bundler or a runtime such as Bun reads first. Leaving module out is the one bad answer: in TypeScript 5.9 it then defaults to commonjs, as the first example shows.
At a glance
| Write | "nodenext" for code Node.js runs; "preserve" with moduleResolution: "bundler" for code a bundler, Bun or another tool reads first |
|---|---|
| On the command line | --module |
| Type | string |
| Default in TypeScript 5.9 | commonjs (with the 5.9 default target, es5; es2015 for newer targets) |
| Default from TypeScript 6.0 | esnext (TypeScript 6.0) |
| TypeScript's description | Specify what module code is generated. |
| In the quick-start setups |
|
The description is quoted from TypeScript's own option list (Apache-2.0); see the notices. The setup values are the checked configs on the quick-start pages.
Examples
import { readFile } from "node:fs/promises";
const notes = await readFile(new URL("./notes.txt", import.meta.url), "utf8");error TS1378: Top-level 'await' expressions are only allowed when the 'module' option is set to 'es2022', 'esnext', 'system', 'node16', 'node18', 'node20', 'nodenext', or 'preserve', and the 'target' option is set to 'es2017' or higher.error TS1343: The 'import.meta' meta-property is only allowed when the '--module' option is 'es2020', 'es2022', 'esnext', 'system', 'node16', 'node18', 'node20', or 'nodenext'.
console.log(notes.length);jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "commonjs",
"types": [
"node"
]
},
"include": [
"*.js"
]
} import { readFile } from "node:fs/promises";
const notes = await readFile(new URL("./notes.txt", import.meta.url), "utf8");
console.log(notes.length);jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": [
"node"
]
},
"include": [
"*.js"
]
} nodenext: Node's own rules
With module: "nodenext" (which also sets moduleResolution to nodenext), the checker reads each file the way Node.js will load it:
- a
.jsfile is an ES module when the nearestpackage.jsonsays"type": "module", and CommonJS otherwise;.mjsfiles are always ES modules and.cjsfiles always CommonJS - relative imports in ES modules need their file extension (TS2835 without it), as Node.js requires
- package imports go through the package's
exportsmap, with theimportorrequirecondition to match the file - JSON imports in ES modules need
with { type: "json" } - since TypeScript 5.8, a CommonJS file may
require()an ES module, which Node.js allows without a flag from 20.19 and 22.12
node16, node18, node20 or nodenext?
nodenext moves with Node.js: each TypeScript release can teach it newer behavior. node16, node18 and node20 are frozen at that Node.js version's rules (node20 arrived with TypeScript 5.9). Use a frozen value when you need checking to stay put across TypeScript upgrades, or when you support an older Node.js; TypeScript's handbook suggests node18 for libraries. nodenext also implies target: "esnext" when you leave target out, and node20 implies es2023.
preserve and esnext: code a bundler reads
When a bundler, Bun, or a framework tool resolves your imports, Node's rules are too strict (the tool adds extensions for you) or simply not the rules that apply. module: "preserve" keeps each import as written and implies moduleResolution: "bundler"; the older pairing is module: "esnext" with moduleResolution: "bundler". Either way the checker allows extensionless relative imports and reads exports maps with the import condition.
rewriteRelativeImportExtensions
TypeScript 5.7 added rewriteRelativeImportExtensions, which turns import "./util.ts" into import "./util.js" in the files tsc writes. It does nothing for a .js project: there is no output, and imports already end in .js. It belongs to TypeScript projects that run .ts files directly, for example with Node.js type stripping.
Gotchas
- A config without
module(and without a recenttarget) is checked as old CommonJS in TypeScript 5.9: top-levelawaitandimport.metaare errors even in a file Node.js runs as an ES module. TypeScript 6.0 changes the default toesnext. "type"inpackage.jsonmatters only undernode16tonodenext; withpreserveoresnext, TypeScript does not ask whether a file is CommonJS.
Questions
What module setting should a JavaScript project use?
"nodenext" if Node.js runs the files as they are. "preserve" (with "moduleResolution": "bundler") if a bundler or Bun reads them first. The quick-start setups show both.
Sources
- TSConfig Reference: module · TypeScript
- Modules: Reference · TypeScript handbook
- Modules: Choosing Compiler Options · TypeScript handbook
- TypeScript 4.7 release notes · TypeScript handbook
- TypeScript 5.8 release notes · TypeScript handbook
- TypeScript 5.7 release notes · TypeScript handbook
- TypeScript 5.9 release notes · TypeScript handbook
- TypeScript 6.0 release notes · TypeScript handbook
Last reviewed 20 October 2026.