Modules

You write modules with the import and export syntax of Export and Import, but TypeScript does not load them: Node.js 2,131 , Deno 60,577 , Bun 73,307 or a bundler such as Vite 25,978 does. Two options tell the compiler how that host behaves. module says which format each output file must use, and moduleResolution says how the host turns a specifier such as "./cart.js" into a file. TypeScript never rewrites your specifiers during emit, so it must reject imports that the real host would fail to find.

Choosing module settings
Code runs in module moduleResolution Write relative imports as
Vite, webpack 3,824 , esbuild 126 , Bun esnext or preserve bundler ./cart
Node.js, compiled by tsc nodenext nodenext (implied) ./cart.js
Node.js type stripping nodenext + rewriteRelativeImportExtensions nodenext ./cart.ts

The handbook is blunt about Node.js: node16, node18, node20 and nodenext are the only correct module values for code it runs, even if every file is ESM. With a bundler, tsc usually only type-checks (noEmit); adding allowImportingTsExtensions then permits ./cart.ts, which otherwise fails with TS5097. TypeScript 6.0 changed the default module to esnext and deprecated the old node10 resolution, which 7.0 removed.

ESM or CommonJS, file by file

Under nodenext, the extension decides a file's format exactly as Node.js does: .mts is always ESM and emits .mjs, .cts is always CommonJS and emits .cjs, and .ts follows the "type" field of the nearest package.json. ESM imports need full extensions, and you write the extension of the output file (.js), because the specifier is copied verbatim. The "imports" field adds subpath imports, private aliases starting with #. Node.js 25.4 and 24.14 accept the short #/ prefix, and TypeScript 6.0 resolves it under nodenext and bundler, mapping dist paths back to the sources in src.

package.json and tsconfig.json for an ESM Node.js projectJSON
{ "name": "shop", "type": "module", "imports": { "#/*": "./dist/*" } }
{ "compilerOptions": { "module": "nodenext", "rootDir": "src", "outDir": "dist",
                       "verbatimModuleSyntax": true, "types": ["node"] } }
verbatimModuleSyntax

Introduced in TypeScript 5.0, verbatimModuleSyntax makes emit predictable: an import disappears only if it is marked type, everything else stays exactly as written. Single-file tools such as esbuild, SWC 553,439 and Node.js type stripping cannot tell whether Money is a type, so the flag makes you say so. It also bans ESM syntax in CommonJS files, where you write import x = require() and export = instead.

src/main.ts and src/lib/legacy.cts under nodenextTypeScript
// src/lib/legacy.cts (CommonJS)
function tax(amount: number) { return amount * 0.2; }
export = tax;
// src/main.ts (ESM because of "type": "module")
import { format, Money } from "#/lib/money.js";      // subpath import
import tax from "./lib/legacy.cjs";                  // default import = module.exports
import { format as f2 } from "./lib/money";          // no extension
const price: Money = { amount: 10, currency: "EUR" };
console.log(format(price), tax(price.amount));
Output
src/main.ts(1,18): error TS1484: 'Money' is a type and must be imported using a type-only
  import when 'verbatimModuleSyntax' is enabled.
src/main.ts(3,30): error TS2835: Relative import paths need explicit file extensions in
  ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean
    './lib/money.js'?

After writing type Money and deleting the extensionless import, tsc emits legacy.cjs with module.exports = tax;, and node dist/main.js prints 10.00 EUR 2. The emitted main.js keeps import { format } from "#/lib/money.js" byte for byte. Writing export const in the .cts file instead fails with TS1287, and an import { readFileSync } from "node:fs" there fails with TS1286.

Interop between ESM and CommonJS
ESM and CommonJS interop under nodenext
Direction Written as Result in Node.js
ESM imports CJS import tax from "./legacy.cjs" Default import is module.exports
CJS requires ESM import m = require("./money.js") in .cts Namespace object (Node.js 22.12+)
CJS exports one value export = tax in .cts module.exports = tax

Named imports from CommonJS work only for exports Node.js can detect statically, so the default import is the safe choice. This behavior is esModuleInterop, which TypeScript 6.0 made permanent (it can no longer be false). Old code that wrote import * as express from "express" and called express() should switch to a default import.