esm-best-practices
ESM-specific patterns for TypeScript and Node.js — file extensions, import.meta, dynamic imports, dual packages, and module resolution modes
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
ESM-specific patterns for TypeScript and Node.js — file extensions, import.meta, dynamic imports, dual packages, and module resolution modes
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Security guidelines for TypeScript and Node.js — input validation, injection prevention, authentication hardening, secrets management, HTTP security headers, and common vulnerability patterns
In-memory caching patterns for TypeScript — memoization, async deduplication, TTL, LRU, stale-while-revalidate, and invalidation strategies
Concrete patterns for composing behavior from small units — function composition, middleware, decorators, mixins, monad chaining, builders, plugin systems, strategy composition, and type-level composition for TypeScript
Guidelines for designing and implementing HTTP REST APIs — resource naming, HTTP semantics, status codes, error responses, pagination, and versioning
Production-ready Playwright patterns — config, locators, assertions, page objects, network mocking, and CI setup for reliable E2E tests.
Advanced TypeScript type system patterns — branded types, discriminated unions, conditional types, mapped types, template literals, type predicates, and variance
| 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 |
Covers the ESM-specific layer on top of TypeScript and Node.js.
"type": "module" in package.jsonSetting "type": "module" makes Node.js treat all .js files in the package as ESM.
{
"type": "module"
}
What it enables:
import/export syntax in .js filesawaitimport.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-onlyimport data from "./data.json" with { type: "json" }Use .mjs / .cjs extensions to override per-file when mixing module systems within one package.
TypeScript's ESM output requires explicit .js extensions on relative imports — even when the source file is .ts:
// ✅ Correct — TypeScript resolves foo.ts but emits the .js extension
import { parse } from "./parser.js";
// ❌ Wrong — fails at runtime; Node cannot find the file without an extension
import { parse } from "./parser";
// ✅ Correct for type-only imports
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:
// ❌ Node ESM does not auto-resolve index files
import { x } from "./utils";
// ✅ Explicit
import { x } from "./utils/index.js";
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.
// When verbatimModuleSyntax: true is set in tsconfig
// ✅ Correct — type-only import is explicitly marked
import type { ParseOptions } from "./parser.js";
// ❌ Compile error — ParseOptions is type-only but not marked `import type`
import { ParseOptions } from "./parser.js";
Prefer named imports — they are statically analyzable and tree-shakeable:
// ✅ Named — only what you use is imported
import { readFile, writeFile } from "node:fs/promises";
// ✅ Namespace — acceptable when using many exports from one module as a unit
import * as path from "node:path";
path.join(a, b);
path.resolve(c);
Use namespace imports (* as) when:
Never use namespace imports to fake a barrel; import directly from the source file.
import()Use dynamic imports for lazy loading and conditional module loading:
// Lazy load — only fetched when called
async function loadPlugin(name: string): Promise<Plugin> {
const { default: plugin } = await import(`./plugins/${name}.js`);
return plugin;
}
// Conditional — avoids loading dev-only code in production
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.metaimport.meta is only available in ESM modules.
// URL of the current module as a string
import.meta.url; // "file:///Users/you/project/src/server.js"
// Node 21.2+ — direct path equivalents of __dirname / __filename
import.meta.dirname; // "/Users/you/project/src"
import.meta.filename; // "/Users/you/project/src/server.js"
// For Node < 21.2, derive paths manually (see Interop Pitfalls)
// Resolve a path relative to the current module
const configPath = import.meta.resolve("./config.json");
// Construct an absolute path to a sibling file
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 is available in ESM entry points and modules:
// ✅ Load config before the module's exports are accessible to importers
const config = await loadConfig("./config.json");
export const db = await connectDatabase(config.databaseUrl);
Caveats:
await blocks all importers until it resolves. Slow or failing awaits delay the entire application startup.await in library modules unless the delay is intentional and documented. Prefer lazy initialization or factory functions.// ✅ Factory pattern — caller controls when initialization runs
export async function createClient(url: string) {
const conn = await connect(url);
return { query: conn.query.bind(conn) };
}
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.
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:
// ✅ Export pure functions and data — no module-level singletons
// ❌ Avoid module-level state that consumers rely on being shared
let _instance: Client | null = null;
export function getInstance() { ... } // breaks if CJS and ESM both load
If your package must hold shared state, expose a factory with an explicit registration step so the host app controls the singleton.
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:
nodenext for Node.js libraries and CLI tools — it enforces .js extensions and correct import conditions.bundler for frontend apps and packages consumed only through a bundler — it matches what bundlers actually do.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.
__dirname / __filename in ESMThese 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 ESMrequire 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.
CJS modules that set module.exports = value are exposed in ESM as the default export:
// CJS module: module.exports = { foo, bar }
import pkg from "some-cjs-package";
const { foo, bar } = pkg; // ✅
// ❌ Named imports from CJS default exports may fail depending on Node/bundler version
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.
| 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 |