Most "it works on my machine" bugs in modern JavaScript are module bugs. A library works from import but not from require. The same class fails an instanceof check against itself. The bundle ships a 400 KB date library for one helper. The type checker is happy while Node throws ERR_PACKAGE_PATH_NOT_EXPORTED. This post goes through the module systems as the spec and Node define them, then how a package tells each consumer which file to load, what bundlers do with the result, and how TypeScript fits in when the codebase has hundreds of packages. It is written against Node 24 LTS, Vite 8, TypeScript 6.0 and 7.0.

Series map: 00 Engines & Runtime → 01 Event Loop & Concurrency → 02 Objects, Prototypes & Metaprogramming → 03 Async Engineering → 04 Modules, Packaging & TypeScript at Scale (this post) → 05 Performance, Memory & Production Debugging (future: see the extensions table in 05).


Learning Outcome

  • Explain ESM's three phases, live bindings, hoisting, cycles and top-level await, and contrast them with CommonJS.
  • Predict what Node does for every mix of import, require, .mjs, .cjs and "type", including require(esm).
  • Write a package.json exports/imports map with conditions in the right order, and check it with tools.
  • Recognise the dual-package hazard and choose a publishing strategy that avoids it.
  • Use import maps, and know what tree-shaking needs from your code (sideEffects, pure annotations, no barrels in hot paths).
  • Describe the 2026 bundler landscape: Vite 8 on Rolldown and Oxc, esbuild, Rollup, and where each fits.
  • Configure TypeScript for a large codebase: strict flags, moduleResolution, declaration output, project references, and Node's built-in type stripping.
  • Read and write core type-level code: generics with constraints, conditional types with infer, mapped types with key remapping.

Chapter 1 — ES modules as the spec defines them

Terminology

  • Module record — the engine's parsed representation of one module: its imports, exports and code.
  • Linking — connecting every import to the exporting module's binding, before any code runs.
  • Binding — a named slot in a module's environment. An import is a read-only view of the exporter's binding, not a copy of its value.
  • Module namespace object — the object you get from import * as ns or await import(): a frozen-shaped, live view of all exports.
  • TDZ (temporal dead zone) — the window between a let/const/class binding being created and being initialised. Reading it throws ReferenceError.

Three phases

A module graph is loaded in three separate phases:

  1. Construction — fetch and parse every module reachable through static import/export … from declarations. The whole graph is known before anything runs. This is why static imports must be at top level with string-literal specifiers.
  2. Linking — create each module's bindings and wire imports to exports. A missing named export is a SyntaxError at this point, before any code runs.
  3. Evaluation — run each module body once, in post-order depth-first order: dependencies before dependents, siblings in source order. If main.js imports a.js and then b.js, and both import c.js, the bodies run in the order c.js, a.js, b.js, main.js.

Consequences:

  • Imports are hoisted. An import on line 50 is linked before line 1 runs.
  • Each module runs once per realm, keyed by its resolved URL. ./a.js?v=2 is a different module from ./a.js.
  • Modules are strict mode, top-level this is undefined, and top-level declarations are module-scoped, not global.

Live bindings

1
2
3
4
5
6
7
8
9
10
11
// counter.js
export let count = 0;
export function increment() { count++; }

// main.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 (the import is a view of counter.js's binding)
count = 5; // TypeError: Assignment to constant variable (imports are read-only)

CommonJS has no bindings. require() returns whatever module.exports was, and destructuring copies the values:

1
2
3
4
5
6
7
8
// counter.cjs
let count = 0;
module.exports = { count, increment() { count++; } };
// main.cjs
const { count, increment } = require('./counter.cjs');
increment();
console.log(count); // 0 (a copy made when the object was created)

Cycles

Both systems allow cycles and both can surprise you, in different ways.

ESM CommonJS
Module in the cycle not finished yet Its bindings exist but are uninitialised: reading a let/const/class export throws ReferenceError (TDZ). function declarations are hoisted and usable. require returns the partial module.exports object as it is at that moment. Missing properties are undefined, and no error is raised.
Typical symptom ReferenceError: Cannot access 'X' before initialization TypeError: x is not a function far from the cause
Fix Break the cycle, move shared code into a third module, or only use cyclic imports inside functions called later Same, or require inside the function that needs it

Trick: madge --circular src/ or dpdm lists cycles. Run one in CI. Cycles through barrel files (index.ts re-exporting a whole folder) are the most common source in large apps.

Top-level await

A module that uses top-level await (ES2022) makes its evaluation asynchronous. Every module that imports it, directly or indirectly, waits for it. Siblings that do not depend on it can still evaluate. The cost is easy to miss: one await fetchConfig() in a shared config.js delays the start of every module above it in the graph.

1
2
3
// db.js
export const pool = await createPool(process.env.DATABASE_URL); // importers wait for this

Use it for real start-up dependencies (config, WASM instantiation, a connection pool) in applications. Avoid it in libraries. It forces an async graph on every consumer, and in Node it makes the module impossible to require() (Chapter 2).

Dynamic import and import.meta

import(specifier) works in both module systems. It returns a promise of the namespace object and is how code-splitting points are expressed. import.meta.url is the module's own URL. Node adds import.meta.dirname and import.meta.filename (Node 20.11+), which replace __dirname/__filename in ESM, and import.meta.resolve(specifier).


Chapter 2 — ESM and CommonJS in Node

How Node decides a file's format

File Format
.mjs ESM, always
.cjs CommonJS, always
.js with nearest package.json "type": "module" ESM
.js with "type": "commonjs" CommonJS
.js with no "type" CommonJS, unless the file contains ESM syntax, in which case Node re-parses it as ESM ("syntax detection")

Watch out: syntax detection costs a second parse and hides mistakes. Always set "type" explicitly in every package.json you own.

ESM importing CommonJS

import x from './legacy.cjs' always works: the default export is module.exports. Named imports (import { a } from './legacy.cjs') work only if Node's static analyser (cjs-module-lexer) can see the names in the source, for example as exports.a = … or module.exports = { a, b }. Exports computed at runtime are invisible to it, so you get a SyntaxError at link time.

CommonJS requiring ESM: require(esm)

For years require() of an ES module threw ERR_REQUIRE_ESM, and that single error drove a decade of dual-publishing. That has changed:

Node version require(esm) status
22.0, 20.17 Added behind --experimental-require-module
23.0, 22.12, 20.19 Enabled by default (no flag)
23.5, 22.13, 20.19 No experimental warning by default
25.4 No longer experimental

Rules:

  • The ES module graph must be fully synchronous: no top-level await anywhere in it. Otherwise you get ERR_REQUIRE_ASYNC_MODULE.
  • require() returns the namespace object. If it has a default export, the namespace also includes __esModule: true, so transpiled CJS consumers see the default export correctly.
  • An ES module can choose what require() returns with export { thing as 'module.exports' }.
  • Detect it with process.features.require_module. Disable it with --no-require-module.
1
2
3
4
5
6
7
8
// lib/point.mjs
export default class Point { constructor(x, y) { this.x = x; this.y = y; } }
export { Point as 'module.exports' }; // require('./point.mjs') returns the class itself

// app.cjs
const Point = require('./lib/point.mjs');
new Point(1, 2);

With Node 20 now past end-of-life and every supported Node line able to require() synchronous ESM, publishing ESM-only is a reasonable default for new libraries. It still depends on your support matrix, as Chapter 4 explains.


Chapter 3 — package.json: exports, imports and conditions

Terminology

  • Entry point — the file a bare specifier (import 'pkg') or subpath (import 'pkg/utils') resolves to.
  • exports — the public map from subpaths to files. Once present, only listed subpaths can be imported. Anything else throws ERR_PACKAGE_PATH_NOT_EXPORTED.
  • Condition — a key inside an exports entry that matches the environment (import, require, node, browser, types, …).
  • Subpath pattern — a * in both key and target, e.g. "./features/*": "./dist/features/*.js".
  • imports — a private map for the package's own code. Keys must start with #.

A realistic exports map

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"name": "@acme/sdk",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"browser": "./dist/index.browser.js",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./features/*": {
"types": "./dist/features/*.d.ts",
"default": "./dist/features/*.js"
},
"./package.json": "./package.json"
},
"imports": {
"#internal/*": "./src/internal/*.js"
},
"sideEffects": false
}

Object key order is the priority order. The resolver walks the keys in the order written and takes the first one that is in its set of active conditions. The order of the active conditions does not matter. Node documents its built-in conditions from most to least specific: node-addons, node, import, require, module-sync, default. default must come last, and types must come first.

Consumer Active conditions (main ones) Resolves @acme/sdk to
TypeScript (nodenext / bundler) types, plus import or require ./dist/index.d.ts
Vite 8, client build module, browser, development|production, plus import ./dist/index.browser.js
Node, import node, import, module-sync, default ./dist/index.js
Node, require() node, require, module-sync, default ./dist/index.cjs

The @acme/sdk exports map from Chapter 3 with its four keys in order (types, browser, import, require) and four consumers: TypeScript picks types, a Vite client build picks browser, Node import picks import, Node require picks require. Loading both index.js and index.cjs in one process is the dual-package hazard

module-sync matches for import and require() in Node versions that support require(esm). It exists so that a package can point both at one ESM file, which removes the dual-package hazard described next. Custom conditions come from node --conditions=development (or -C development), and bundlers have their own setting (resolve.conditions in Vite).

Common pitfall: putting "types" after "import". TypeScript stops at the first matching key, which is import, and then looks for ./dist/index.d.ts next to the JS file by extension. That may or may not exist. Run npx @arethetypeswrong/cli --pack and npx publint before every release. Between them they catch most exports mistakes.

Subpath imports

1
2
3
// anywhere inside @acme/sdk
import { hash } from '#internal/crypto'; // → ./src/internal/crypto.js

imports replaces fragile ../../../ paths and tsconfig paths aliases with something Node, bundlers and TypeScript all understand natively. Entries can have conditions too, e.g. a node and a default implementation of #internal/fs.


Chapter 4 — Dual packages and the hazard

A dual package ships both an ESM build and a CJS build. The dual-package hazard: in one process, some code imports the package and some requires it. Node then loads both files, creating two module instances with two copies of every piece of module state.

Symptoms:

  • err instanceof SdkError is false for an error thrown by "the same" class.
  • A singleton registry, cache or plugin list exists twice, and registrations go missing.
  • Two copies of a framework runtime complain that hooks or context are being called from a different copy.

Strategies, from most to least preferred in 2026:

Strategy How Trade-off
ESM-only Ship ESM; CJS consumers use require(esm) Needs Node 20.19+ / 22.12+ for CJS consumers; no top-level await in the graph
One ESM file for everyone "module-sync" and "import" point at the ESM file, "require" points at a fallback only for old Node Old Node still gets two copies, which is acceptable if old Node never mixes the two
CJS core + thin ESM wrapper index.mjs does import cjs from './index.cjs'; export const { a, b } = cjs; One instance of state; ESM consumers lose tree-shaking
Stateless dual build Two full builds, no module-level state, no instanceof across the API Works only if you can keep that discipline forever

Trick: use Symbol.for('@acme/sdk.Registry') on globalThis for state that must be a true singleton, and a brand check (static [Symbol.hasInstance] or an isSdkError() function) instead of instanceof. This survives two copies and even two different versions in node_modules.


Chapter 5 — Modules in the browser

Browsers load ESM natively: <script type="module"> is deferred by default, runs in strict mode, and is fetched with CORS. What they cannot do by themselves is resolve bare specifiers like 'lit'. Import maps (Baseline since 2023) fill that gap:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script type="importmap">
{
"imports": {
"lit": "https://cdn.jsdelivr.net/npm/lit@3/index.js",
"lit/": "https://cdn.jsdelivr.net/npm/lit@3/",
"#app/": "/static/app/"
},
"scopes": {
"/static/legacy/": { "lit": "https://cdn.jsdelivr.net/npm/lit@2/index.js" }
}
}
</script>

<link rel="modulepreload" href="/static/app/main.js">
<script type="module" src="/static/app/main.js"></script>

Import maps are enough for small apps, internal tools, and micro-frontends that share one copy of a framework. For large apps, bundling still wins: fewer round trips, dead-code removal, minification, and hashed file names that can be cached for a long time. modulepreload fixes the discovery waterfall (A imports B imports C) only when you list the deep modules yourself.


Chapter 6 — Tree-shaking and bundlers

What tree-shaking needs

Tree-shaking removes exports nobody imports. It relies on ESM being static: the bundler can see every import and export without running code. It fails when:

  • Code is CommonJS. module.exports is a runtime object, so bundlers must keep all of it (with a few heuristics).
  • A module might have side effects. A top-level polyfill(), a class with static initialisers, or Object.defineProperty(window, …) must be kept even if nothing is imported from the module. The bundler has to assume that side effects exist unless told otherwise.
  • The code uses namespace objects dynamically. ns[key] with a computed key keeps the whole namespace.

Tell the bundler what is safe:

1
2
{ "sideEffects": ["*.css", "./dist/polyfills.js"] }
1
2
3
export const theme = /*#__PURE__*/ createTheme({ … });   // this call may be dropped if unused
/*#__NO_SIDE_EFFECTS__*/ export function defineComponent(o) { return o; } // every call is droppable

"sideEffects": false claims that no file in the package does anything on import, so the bundler may skip whole modules. Lying here breaks CSS imports and polyfills silently.

Watch out: barrel files (index.ts that re-exports 300 components) are cheap in production builds with correct sideEffects, but expensive everywhere else. The dev server, test runner and type checker all load and transform every re-exported module. Large codebases import from the leaf module (@acme/ui/button), and expose subpath exports to allow it.

The 2026 landscape

Tool What it is Where it fits
Vite 8 (March 2026) Dev server + build, now on Rolldown (Rust bundler) and Oxc (Rust parser/transformer/minifier) Default for SPAs and many frameworks. Needs Node 20.19+ / 22.12+. esbuild is no longer a direct dependency
Rolldown Rust bundler with a Rollup-compatible plugin API Under Vite, or on its own for libraries
Rollup The original ESM-first bundler, JS Library builds; its plugin API is the ecosystem standard
esbuild Go bundler/transpiler, still 0.x, extremely fast Scripts, CLIs, Lambda bundles, and as a transformer inside other tools
webpack / Rspack webpack is the mature JS original; Rspack is a Rust reimplementation with a compatible API Large legacy configs; Module Federation
Turbopack Next.js's Rust bundler Next.js only; see the Next.js Deep Dive series

Before Vite 8, the dev server used esbuild and production builds used Rollup. The two could transform code differently, and occasionally a bug appeared only in vite build. Vite 8 uses one bundler for both, which removes that whole class of problem. It also opens the way to a full-bundle dev mode and module-level persistent caching.

Trick: measure before tuning. vite build with rollup-plugin-visualizer (or npx vite-bundle-visualizer) shows what is actually in each chunk. The usual big wins are a duplicated dependency, a locale-data import, or a CJS-only library that cannot be tree-shaken.


Chapter 7 — TypeScript at scale

Where TypeScript is in 2026

  • TypeScript 6.0 (March 2026) is the last release built on the JavaScript codebase. It changed defaults: strict: true, module: esnext, target: es2025, types: [] (no more automatic inclusion of every @types package), and rootDir: ".". It also deprecated a long list of legacy options: moduleResolution: node/node10 and classic, target: es5, --downlevelIteration, --outFile, baseUrl as a lookup root, and the amd/umd/systemjs module formats.
  • TypeScript 7.0 (July 2026) is the native port, written in Go. The announcement reports typical full-build speed-ups of 8–12×. It still installs as typescript and runs as tsc. The deprecated 6.0 options are now hard errors. 7.0 ships without a programmatic API, which is planned for 7.1. So tools that drive the compiler through its API (Vue, Svelte, Astro and MDX language tooling, some lint rules) may need to stay on 6.0 for now.

A strict baseline

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"compilerOptions": {
"strict": true, // default in 6.0, keep it explicit
"noUncheckedIndexedAccess": true, // arr[i] is T | undefined
"exactOptionalPropertyTypes": true, // {a?: string} does not accept a: undefined
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"verbatimModuleSyntax": true, // imports are emitted exactly as written
"isolatedModules": true, // every file transpilable on its own
"module": "nodenext", // or "preserve" + "moduleResolution": "bundler"
"target": "es2024",
"skipLibCheck": true
}
}

noUncheckedIndexedAccess is the flag that finds the most real bugs in existing code, and it is also the noisiest to turn on. Enable it per package through project references rather than all at once.

module / moduleResolution: pick by runtime

Code runs in Setting Effect
Node (apps and libraries) "module": "nodenext" Follows Node's real rules: "type", exports, conditions, file extensions required in relative ESM imports
A bundler (Vite, Next.js, …) "module": "preserve", "moduleResolution": "bundler" Supports exports, allows extensionless relative imports as bundlers do
A library published for both nodenext The strictest option; if it type-checks under nodenext it works in bundlers too

Declaration files

Libraries publish .d.ts files ("declaration": true, plus "declarationMap": true so that "go to definition" lands in the source). isolatedDeclarations (TS 5.5) requires explicit return and export types, so that each file's declarations can be produced without the type checker. Fast tools such as Oxc can then emit .d.ts files in parallel. In a monorepo this removes the slowest step from the build graph.

Project references

1
2
3
4
5
6
7
8
9
// tsconfig.json (root): a solution file that only lists packages
{ "files": [], "references": [{ "path": "packages/core" }, { "path": "packages/web" }] }

// packages/web/tsconfig.json
{
"compilerOptions": { "composite": true, "outDir": "dist", "rootDir": "src" },
"references": [{ "path": "../core" }]
}

tsc -b builds packages in dependency order and skips any whose inputs have not changed, using .tsbuildinfo files. composite forces the settings that make this safe: declarations on, and every file listed. TypeScript 7 adds --builders to build independent references in parallel. Editors also load only the projects you open, which is what keeps a 500-package monorepo responsive.

Node's built-in type stripping

Node runs .ts files directly by replacing type syntax with whitespace:

Version Change
22.6 --experimental-strip-types added
23.6, 22.18 Type stripping enabled by default
25.2, 24.12 Type stripping stable
26.0 --experimental-transform-types removed

It does no type checking, ignores tsconfig.json, refuses files under node_modules, and only handles erasable syntax. enum, namespace with runtime code, parameter properties and import = aliases need a transform and fail. Imports must use the real extension (import './util.ts'), and type-only imports must say import type. The matching compiler settings:

1
2
3
4
5
6
7
8
9
10
{
"compilerOptions": {
"noEmit": true,
"module": "nodenext",
"rewriteRelativeImportExtensions": true, // TS 5.7: emit .js for './x.ts' if you do build
"erasableSyntaxOnly": true, // TS 5.8: error on enums, namespaces, …
"verbatimModuleSyntax": true
}
}

Then node src/server.ts runs, and tsc --noEmit (or tsc -b) type-checks in CI.


Chapter 8 — Type-level programming essentials

Types are a small functional language that runs at compile time. Four features cover most real code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 1. Generics with constraints: relate inputs to outputs.
function pick<T extends object, K extends keyof T>(obj: T, keys: readonly K[]): Pick<T, K> {
const out = {} as Pick<T, K>;
for (const k of keys) out[k] = obj[k];
return out;
}
const p = pick({ id: 1, name: 'a', admin: true }, ['id', 'name']); // { id: number; name: string }

// 2. Conditional types with infer: pattern-match on a type.
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;
type UnwrapPromise<T> = T extends PromiseLike<infer U> ? UnwrapPromise<U> : T; // like built-in Awaited

// 3. Distribution: a naked type parameter distributes over a union.
type ToArray<T> = T extends unknown ? T[] : never;
type A = ToArray<string | number>; // string[] | number[]
type ToArrayNoDist<T> = [T] extends [unknown] ? T[] : never;
type B = ToArrayNoDist<string | number>; // (string | number)[]

// 4. Mapped types with key remapping and template literal types.
type Getters<T> = { [K in keyof T & string as `get${Capitalize<K>}`]: () => T[K] };
type G = Getters<{ id: number; name: string }>; // { getId: () => number; getName: () => string }
type Mutable<T> = { -readonly [K in keyof T]: T[K] };

Two operators that keep type-level code small:

  • satisfies checks a value against a type without widening it: const routes = { home: '/', user: '/u/:id' } satisfies Record<string, string>; keeps the literal keys.
  • const type parameters (function f<const T>(x: T)) infer ['a', 'b'] as a readonly tuple instead of string[].

Watch out: deep recursive conditional types slow down the checker for everyone who imports them. If tsc --extendedDiagnostics shows check time dominated by one file, or tsc --generateTrace points at one type, simplify that type. Clever types are a cost the whole team pays on every keystroke.


Practice Exercises

Exercise 1 — Live binding or copy?

Given export let n = 1; export function bump() { n++; }, what do an ESM importer and a CJS consumer using require(esm) each see after calling bump()?

Solution

Both see 2 through ns.n or a named import. require(esm) returns the module namespace object, which is a live view. The CJS consumer loses liveness only if it destructures: const { n, bump } = require('./m.mjs') copies n at that moment, and it stays 1.

Exercise 2 — Fix the exports map

A package has "exports": { ".": { "import": "./dist/index.mjs", "types": "./dist/index.d.ts", "require": "./dist/index.cjs" } }. TypeScript users with moduleResolution: nodenext who import it report missing types, while require users are fine. Why, and what is the fix?

Solution

Key order is priority. For an ESM consumer, TypeScript matches import first and never reaches types. It resolves ./dist/index.mjs and looks for the sibling declaration file ./dist/index.d.mts, which does not exist. A require consumer skips import, so it reaches types. Put types first. If the ESM and CJS builds need different declarations, nest them: "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }. Verify with npx @arethetypeswrong/cli --pack.

Exercise 3 — Diagnose the double singleton

An app registers plugins with @acme/sdk, but plugins registered from a CJS config file never show up. The app code uses import. Explain the bug and give two fixes.

Solution

This is the dual-package hazard. The app loads dist/index.js and the config loads dist/index.cjs: two module instances, two registries. Fixes: (1) publish ESM-only, or point module-sync/require at the ESM file, so that require(esm) loads the same instance; (2) store the registry on globalThis under Symbol.for('@acme/sdk.registry') so that both copies share it.

Exercise 4 — Why is it in the bundle?

A 3 KB feature ships 180 KB. The bundle visualiser shows all of @acme/icons, imported as import { Check } from '@acme/icons'. List three possible causes.

Solution

(1) @acme/icons ships CommonJS only, so it cannot be tree-shaken. (2) It has no "sideEffects": false, so the bundler keeps every module the barrel re-exports. (3) Its icons are built by top-level calls (createIcon(…)) without /*#__PURE__*/, so each call counts as a possible side effect. Fix at the source, or import from a subpath (@acme/icons/check) if the package exports one.

Exercise 5 — Strip-types readiness

Which of these lines fail under Node's type stripping, and why? enum Color { Red }, import { User } from './types.ts' (User is a type), class A { constructor(private x: number) {} }, const x = y as unknown as Z;

Solution

The enum fails: it needs code generation. The parameter property private x fails for the same reason. The import { User } fails at runtime because it is treated as a value import of a name that types.ts does not export at runtime. Write import type { User }. The as casts are erasable and fine. erasableSyntaxOnly plus verbatimModuleSyntax catch all three failures at compile time.

Exercise 6 — Write a type

Write DeepReadonly<T> that makes nested objects and arrays readonly, but leaves functions alone.

Solution
1
2
3
4
5
6
type DeepReadonly<T> =
T extends (...args: any[]) => unknown ? T :
T extends readonly (infer E)[] ? ReadonlyArray<DeepReadonly<E>> :
T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } :
T;

The function check comes first, because functions are objects and would otherwise be mapped. Arrays are handled before plain objects, so that they become ReadonlyArray rather than objects with numeric keys.


Cheat-sheet

Task How
Force a file's format .mjs / .cjs, or "type" in the nearest package.json
__dirname in ESM import.meta.dirname (Node 20.11+)
require an ES module Works by default in Node 20.19+ / 22.12+ if the graph has no top-level await
Control what require(esm) returns export { x as 'module.exports' }
Public entry points only "exports" map; add "./package.json" if tools need it
Condition order Object key order: types first, default last
One file for import and require "module-sync" condition
Private aliases "imports": { "#internal/*": "./src/internal/*.js" }
Check a package before publishing npx @arethetypeswrong/cli --pack, npx publint
Tree-shake safely ESM output, "sideEffects", /*#__PURE__*/
Find import cycles madge --circular or dpdm
Bare specifiers without a bundler <script type="importmap">
TS for Node code "module": "nodenext"
TS for bundled code "module": "preserve", "moduleResolution": "bundler"
Monorepo builds composite + references + tsc -b (--builders in TS 7)
Fast .d.ts output isolatedDeclarations
Run .ts in Node node file.ts (22.18+ / 23.6+), with erasableSyntaxOnly

What's next

Post 05 closes the series with production concerns: Core Web Vitals and their thresholds, the Performance panel and flame charts, long tasks and scheduler.yield(), finding memory leaks with heap snapshots and allocation timelines, profiling Node with --inspect and --cpu-prof, source maps, production error monitoring and RUM, and why most micro-benchmarks lie.


References