ESM vs. CommonJS: Getting TypeScript Package Output Configuration Right
AI generated
<T>
type
TypeScript · ESM · CommonJS · Build Configuration
ESM vs. CommonJS in TypeScript
output configuration without runtime surprises

A misconfigured module field in tsconfig.json or an incomplete exports map in package.json regularly causes ERR_REQUIRE_ESM or ERR_UNKNOWN_FILE_EXTENSION errors in TypeScript packages that only surface at the consumer's end. Understanding the differences between ESM and CommonJS at compile time avoids the dual-package hazard and delivers packages that work reliably in both worlds.

17 min read module · moduleResolution · exports map · dual package TypeScript 5.x · Node.js 20/22

1. Why the choice of output format matters at all

A TypeScript package compiled for the wrong target environment works perfectly fine locally for its author and only breaks at the consumer's end with a cryptic error message. The compiler itself does not check runtime compatibility between the chosen output format and the environment the package is later imported into. Only Node.js or a bundler decides at runtime whether a file is interpreted as ESM or as CommonJS, and it is exactly at this boundary that most problems arise.

Especially for libraries meant to work both in modern ESM projects and older CommonJS codebases, a single output format is often not enough. A require() call on a pure ESM package fails with ERR_REQUIRE_ESM, while an import statement on an incorrectly declared CommonJS file fails with ERR_UNKNOWN_FILE_EXTENSION. The correct configuration of module, moduleResolution and the exports map in package.json determines whether a TypeScript package works reliably in both ecosystems or becomes a support nightmare.

2. ESM and CommonJS: the fundamental differences

CommonJS is Node.js's original module system: modules are loaded synchronously with require(), exports flow through the module.exports object, and module path resolution happens at runtime. ESM (ECMAScript Modules) is JavaScript's own official standard, uses the static import/export syntax, loads asynchronously, and lets the engine analyze the complete dependency graph before execution even starts, which among other things enables more reliable tree shaking.

One key technical difference concerns module-level this and top-level await: in CommonJS, module-level this is an empty object, in ESM it is undefined. ESM modules allow await directly at the top level without an enclosing async function, while CommonJS modules do not support that. Node.js detects the format primarily through the file extension (.mjs for ESM, .cjs for CommonJS) or via the "type" field in package.json, which serves as the default for .js files without an explicit extension.

3. Choosing the module field in tsconfig.json correctly

The module field in tsconfig.json determines which JavaScript module format TypeScript translates import and export statements into. "module": "CommonJS" translates import/export into require()/module.exports calls and is the right choice for Node.js projects that should continue to ship as CommonJS. "module": "ESNext" or "module": "ES2022" leaves import/export syntax unchanged in the output, intended for bundlers like Webpack or for native ESM environments.

Since TypeScript 5.0, there are additionally "module": "Node16" and "module": "NodeNext", which do not fix the output format globally but decide per file based on the file extension and the "type" field in package.json, exactly as Node.js itself does at runtime. These two options are the only ones that actually replicate correctly the complex interplay between .mts/.cts file extensions, the type field and the exports map, which is why they are the right choice for any new package with mixed or unclear target formats.


{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2022",
    "declaration": true,
    "outDir": "dist/esm",
    "rootDir": "src"
  }
}

4. moduleResolution: bundler, node16 and nodenext

While module determines the output format, moduleResolution controls which algorithm TypeScript uses at compile time to resolve import paths, meaning which file an import './utils' actually references. "moduleResolution": "bundler", introduced in TypeScript 5.0, mimics the resolution behavior of modern bundlers like Vite or esbuild, which often do not require file extensions for relative imports, while Node.js itself requires the full file extension including .js for ESM imports.

This apparent triviality is one of the most common pitfalls: a TypeScript project with "moduleResolution": "bundler" happily compiles import { helper } from './utils' without an extension, but as soon as the compiled result is run directly with Node.js (without a bundler in between), the import fails, because Node.js strictly expects ./utils.js for ESM. For packages consumed as a pure Node.js library without a bundler step in between, "moduleResolution": "NodeNext" together with explicit .js extensions in your own imports is therefore the more robust choice, even though the source files themselves are named .ts.


// WRONG with moduleResolution: NodeNext — fails at runtime under plain Node.js
import { helper } from './utils';

// RIGHT — explicit .js extension, even though the source file is utils.ts
import { helper } from './utils.js';

// tsconfig.json excerpt for a pure Node.js library without a bundler step
// {
//   "compilerOptions": {
//     "module": "NodeNext",
//     "moduleResolution": "NodeNext"
//   }
// }

5. Building the exports map in package.json correctly

The exports map in package.json has been the modern, preferred way since Node.js 12 to define which files of a package are visible externally and through which path consumers may import them. For TypeScript packages with dual-format support, each entry typically contains four keys: types for the .d.ts file, import for the ESM entry point, require for the CommonJS entry point, and optionally default as a fallback. The order of these keys is not cosmetic: types must come first, since TypeScript tools evaluate the map top to bottom and can otherwise resolve the wrong type.

A common mistake is leaving the main field pointing only to the CommonJS file while the exports map already provides an ESM format. Older tools that don't yet respect the exports map then fall back to an inconsistent main field. The robust solution is to keep main, module and exports in sync, or, where possible, work exclusively through the exports map and deliberately treat older tools as unsupported.


{
  "name": "@mironsoft/data-utils",
  "version": "3.2.0",
  "type": "module",
  "main": "./dist/cjs/index.cjs",
  "module": "./dist/esm/index.js",
  "types": "./dist/esm/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/esm/index.d.ts",
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.cjs",
      "default": "./dist/esm/index.js"
    },
    "./package.json": "./package.json"
  }
}

6. Dual-package builds: shipping both formats at once

A dual-package build compiles the same TypeScript source twice with different tsconfig profiles, once with "module": "ESNext" for the ESM output and once with "module": "CommonJS" for the CommonJS output. Both outputs land in separate directories, usually dist/esm and dist/cjs, with the CommonJS files given the .cjs extension so Node.js correctly recognizes them as CommonJS regardless of the surrounding "type" field.

The second tsconfig build for CommonJS typically requires its own package.json inside dist/cjs with the content { "type": "commonjs" }, so Node.js picks the right interpretation while traversing the directory tree, even if the root package.json declares "type": "module". Build tools like tsup or unbuild automate exactly this dual-build process, including the correct file extensions and the additional package.json markers, making manual upkeep of two separate tsconfig files unnecessary in many projects.


#!/usr/bin/env bash
set -euo pipefail

# Manual dual-build without a bundler tool

# ESM build
npx tsc -p tsconfig.esm.json

# CommonJS build
npx tsc -p tsconfig.cjs.json

# Mark CommonJS output explicitly so Node.js does not
# reinterpret .js files there as ESM via the root "type" field
echo '{"type":"commonjs"}' > dist/cjs/package.json

# Rename .js to .cjs for unambiguous resolution
find dist/cjs -name '*.js' -exec sh -c 'mv "$1" "${1%.js}.cjs"' _ {} \;

7. The dual-package hazard and how to avoid it

The dual-package hazard describes a subtle problem: when an application loads the same package once via require() (CommonJS build) and once via import (ESM build), for instance because two different dependencies reference it in different ways, the application ends up with two separate instances of the same module, each with its own internal state. For stateful modules like singletons, caches or registries, this leads to bugs that are extremely hard to reproduce, since both code paths work correctly on their own but do not share the same state.

The most effective prevention is to avoid stateful logic in TypeScript packages or, where unavoidable, to share the state explicitly via globalThis instead of keeping it in module scope. For pure utility libraries without internal state, the dual-package hazard is usually harmless, since both instances remain independently functional. Library authors should make this distinction explicit in their documentation so consumers know whether their package can safely be loaded multiple times in mixed ESM/CommonJS environments.

8. Interop traps: default exports and require() of ESM

One of the most common sources of error when combining ESM and CommonJS in TypeScript concerns export default. Compiling a file with export default function foo() {} to CommonJS does not place the function directly onto module.exports, but onto module.exports.default, unless esModuleInterop is enabled. Without this flag, a CommonJS consumer would have to explicitly write require('./foo').default, which is unintuitive and error-prone for most teams.

"esModuleInterop": true combined with "allowSyntheticDefaultImports": true solves this problem by having TypeScript automatically insert an interop helper function during compilation that correctly extracts a default export from a CommonJS module and vice versa. Since Node.js 22, there is additionally the experimental feature of loading ESM modules directly with require(), provided the module contains no unresolved top-level await calls, which eases the interop situation long term but is not yet sufficient as a standalone solution for production TypeScript packages today.


// source.ts
export default function createLogger() {
  return { log: (msg: string) => console.log(msg) };
}

// WITHOUT esModuleInterop, compiled to CommonJS:
// const createLogger = require('./source').default; // must add .default

// WITH "esModuleInterop": true, TypeScript inserts an interop helper
// so consumers can write the intuitive form instead:
import createLogger from './source';
const logger = createLogger();

9. ESM and CommonJS strategies compared

Depending on the target audience of a TypeScript package, a different output strategy makes sense. The following overview compares the common approaches.

Strategy Compatibility Build effort Recommendation
CommonJS only Works everywhere Low Internal Node tools, no bundler users
ESM only No require() possible Low New projects without CommonJS baggage
Dual package (ESM + CJS) Both worlds High without tooling Public npm packages
Dual package with tsup/unbuild Both worlds Low thanks to automation Recommended default for libraries
module: NodeNext without dual build One format, correctly resolved Low Internal monorepo packages

For internal tools and monorepo packages without external consumers, a single format with correctly configured module: NodeNext is usually enough. Public npm packages, on the other hand, almost always benefit from a dual-package build, ideally automated via tsup or unbuild, to keep the manual configuration effort and the error-proneness of a hand-maintained exports map low.

Mironsoft

TypeScript package architecture, build configuration and npm publishing

No more ERR_REQUIRE_ESM at your consumers' end?

We configure module, moduleResolution and the exports map for your TypeScript packages, set up reliable dual-package builds, and eliminate interop traps between ESM and CommonJS.

Package audit

Checking exports map, main/module fields and tsconfig for inconsistencies

Dual-build setup

Automated ESM and CommonJS build with correct file extensions

Migration consulting

Gradual transition of existing CommonJS packages to ESM support

10. Summary

Choosing between ESM and CommonJS in TypeScript is not merely a matter of taste, it has direct consequences for whether a package works reliably for consumers. "module": "NodeNext" together with "moduleResolution": "NodeNext" is the most robust base setting because it mirrors the same resolution behavior Node.js uses at runtime. For public libraries, there is no way around a dual-package build with a clean exports map, ideally automated via tools like tsup.

The dual-package hazard and interop traps around export default are the two most common causes of hard-to-reproduce bugs at the boundary between ESM and CommonJS. With esModuleInterop, correctly set file extensions and a consistently maintained exports map, both problems can be avoided systematically instead of being debugged again with every new consumer.

ESM vs. CommonJS in TypeScript — Key Takeaways

module: NodeNext

Mirrors Node.js's actual resolution behavior per file, instead of forcing a global format.

exports map

types, import, require and default in this order, kept in sync with main and module.

Dual-package hazard

Two loaded instances of the same module with mixed require()/import. Avoid state or share it globally.

esModuleInterop

Resolves the default-export trap between CommonJS and ESM automatically at compile time.

11. FAQ: ESM vs. CommonJS in TypeScript

1module vs. moduleResolution difference?
module determines output format, moduleResolution resolves import paths at compile time.
2Why types first in exports?
Tools evaluate the map top to bottom, otherwise the wrong type gets resolved.
3What is the dual-package hazard?
Two separate module instances with their own state when require() and import mix for the same dependency.
4Why ERR_REQUIRE_ESM?
require() loads synchronously and cannot directly include a pure ESM module without a CommonJS variant.
5Why package.json in dist/cjs?
type: commonjs guarantees correct interpretation regardless of the root package.json.
6Does esModuleInterop fix everything?
Fixes the most common default-export trap, but does not cover every edge case.
7Enough with bundler resolution for npm packages?
No, NodeNext is more robust for pure Node libraries without a bundler.
8Does require() load ESM since Node 22?
Experimentally yes, but not yet a standalone compatibility strategy for production packages.
9Which tools automate dual builds?
tsup and unbuild generate ESM and CommonJS output plus the exports map automatically.
10Is the hazard relevant to every package?
No, only for modules with shared internal state such as singletons or caches.