| name | esm-best-practices |
| description | ESM-specific patterns for TypeScript and Node.js โ file extensions, import.meta, dynamic imports, dual packages, and module resolution modes |
ESM Best Practices
Covers the ESM-specific layer on top of TypeScript and Node.js.
"type": "module" in package.json
Setting "type": "module" makes Node.js treat all .js files in the package as ESM.
{
"type": "module"
}
What it enables:
import/export syntax in .js files
- Top-level
await
import.meta.url โ module-relative URL (see the import.meta section; import.meta.dirname requires Node 21.2+)
What it breaks:
require() โ no longer available (use dynamic import() or convert callers)
__dirname / __filename โ not defined in ESM (see Interop Pitfalls)
module.exports / exports โ CJS-only
- JSON imports require an import assertion:
import data from "./data.json" with { type: "json" }
Use .mjs / .cjs extensions to override per-file when mixing module systems within one package.
File Extensions in Imports
TypeScript's ESM output requires explicit .js extensions on relative imports โ even when the source file is .ts:
import { parse } from "./parser.js";
import { parse } from "./parser";
import type { ParseOptions } from "./parser.js";
The .js extension refers to the emitted file, not the source. TypeScript understands this under moduleResolution: node16 | nodenext | bundler.
Importing a directory index requires an explicit path too:
import { x } from "./utils";
import { x } from "./utils/index.js";
Type-Only Imports
With verbatimModuleSyntax: true in tsconfig, type-only imports must use import type. The compiler emits an error if a type-only symbol is imported without the type keyword โ it cannot safely erase the import at emit time otherwise.
import type { ParseOptions } from "./parser.js";
import { ParseOptions } from "./parser.js";
Named vs Namespace Imports
Prefer named imports โ they are statically analyzable and tree-shakeable:
import { readFile, writeFile } from "node:fs/promises";
import * as path from "node:path";
path.join(a, b);
path.resolve(c);
Use namespace imports (* as) when:
- You use many exports from a single module and grouping adds readability
- Calling module-level functions that share a common prefix makes them clearer together
- You are re-exporting a third-party module under a local alias
Never use namespace imports to fake a barrel; import directly from the source file.
Dynamic import()
Use dynamic imports for lazy loading and conditional module loading:
async function loadPlugin(name: string): Promise<Plugin> {
const { default: plugin } = await import(`./plugins/${name}.js`);
return plugin;
}
if (process.env.NODE_ENV !== "production") {
const { inspect } = await import("node:util");
console.log(inspect(value, { depth: null }));
}
Dynamic import() always returns a Promise<Module> โ the module object, not the default export. Destructure default explicitly if needed.
Avoid dynamic imports in hot paths โ static imports are analyzed at load time; dynamic imports run the full module resolution pipeline on every call.
import.meta
import.meta is only available in ESM modules.
import.meta.url;
import.meta.dirname;
import.meta.filename;
const configPath = import.meta.resolve("./config.json");
import { fileURLToPath } from "node:url";
const assetPath = fileURLToPath(new URL("../assets/logo.png", import.meta.url));
import.meta.resolve returns a file:// URL string, not a filesystem path. Wrap with fileURLToPath if you need a path.
Top-Level Await
Top-level await is available in ESM entry points and modules:
const config = await loadConfig("./config.json");
export const db = await connectDatabase(config.databaseUrl);
Caveats:
- A module with top-level
await blocks all importers until it resolves. Slow or failing awaits delay the entire application startup.
- Circular imports involving a top-level-awaiting module can deadlock โ the import graph stalls waiting for a module that is waiting for itself.
- Never use top-level
await in library modules unless the delay is intentional and documented. Prefer lazy initialization or factory functions.
export async function createClient(url: string) {
const conn = await connect(url);
return { query: conn.query.bind(conn) };
}
Dual CJS/ESM Packages
Expose both module formats using the exports field:
{
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./utils": {
"types": "./dist/utils.d.ts",
"import": "./dist/utils.js",
"require": "./dist/utils.cjs"
}
}
}
The types condition must come before import/require โ TypeScript resolves conditions in order.
Dual Package Hazard
When both CJS and ESM entry points are loaded in the same process, any module-level state (singletons, caches, registries) is duplicated โ one copy per format. Guard against this:
let _instance: Client | null = null;
export function getInstance() { ... }
If your package must hold shared state, expose a factory with an explicit registration step so the host app controls the singleton.
Module Resolution Modes in tsconfig
moduleResolution | When to use |
|---|
node16 | Node.js 16+ with "type": "module" or .mts/.cts files |
nodenext | Alias for the latest node* behavior โ prefer over node16 |
bundler | Vite, esbuild, webpack โ extensions optional, CJS interop easy |
Practical guidance:
- Use
nodenext for Node.js libraries and CLI tools โ it enforces .js extensions and correct import conditions.
- Use
bundler for frontend apps and packages consumed only through a bundler โ it matches what bundlers actually do.
- Never use
node, node10, or classic in new projects โ they do not understand the exports field.
Pair nodenext with "module": "nodenext" and "target": "es2022" (or higher) for full ESM output.
Common Interop Pitfalls
__dirname / __filename in ESM
These globals do not exist in ESM. For Node 21.2+, use import.meta.dirname / import.meta.filename. For older Node:
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
require() in ESM
require is not defined in ESM. If you must call a CJS-only API:
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const legacy = require("./legacy-cjs-module.js");
Prefer converting the CJS module to ESM. Use createRequire only as a temporary bridge.
Default Export Interop
CJS modules that set module.exports = value are exposed in ESM as the default export:
import pkg from "some-cjs-package";
const { foo, bar } = pkg;
import { foo } from "some-cjs-package";
With moduleResolution: nodenext, TypeScript enforces this and will error on incorrect named imports from CJS packages. Set "esModuleInterop": true if consuming CJS packages that have a single default export.
Common Mistakes
| Mistake | Fix |
|---|
Omitting .js extension on relative imports | Always write ./foo.js even when the source is .ts |
Using __dirname / __filename in ESM | Use import.meta.dirname (Node 21.2+) or fileURLToPath + dirname |
Calling require() in an ESM file | Use createRequire(import.meta.url) or convert to import |
Top-level await in a library module | Use a factory function โ callers control initialization timing |
| Module-level singletons in a dual package | Dual loading creates two instances; use explicit registration |
Named imports from a CJS module.exports object | Destructure the default export; don't rely on named re-exports |
moduleResolution: node with "type": "module" | Use nodenext โ node ignores the exports field entirely |
Missing "types" condition before "import" in exports | TypeScript resolves conditions in order; types must be first |
Dynamic import() in hot paths | Use static imports; dynamic imports add resolution overhead per call |
| JSON import without assertion | Add with { type: "json" } or use readFile + JSON.parse |
Importing a type-only symbol without import type | verbatimModuleSyntax: true requires import type for all type-only imports |