Skip to content

Dual Packages and the Exports Field

intermediate14 min read

The Package Author's Dilemma

You're publishing a package. Half your users are on ESM, half are on CJS. Some use TypeScript, some don't. Some only need one utility function from your entire library. How do you ship a package that works for everyone without creating the dual module hazard?

The answer is the exports field in package.json — the most powerful and most misunderstood feature in Node.js module resolution. It gives you fine-grained control over what consumers can import, how they import it, and which version they get based on their module system.

Mental Model

Think of package.json exports as a receptionist at a building entrance. Instead of letting visitors wander to any room (the old main field behavior), the receptionist directs each visitor to the right room based on who they are. CJS users go to the CJS version. ESM users go to the ESM version. TypeScript users get the type definitions. And nobody can access the internal rooms that aren't on the visitor list.

The Evolution: main → exports

The old way: main

{
  "name": "my-package",
  "main": "./dist/index.js"
}

The main field is a single entry point. It doesn't distinguish between CJS and ESM consumers. It can't restrict which files are importable. It's been around since the beginning of npm and it still works, but it's limited.

The new way: exports

{
  "name": "my-package",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.cjs",
      "default": "./dist/esm/index.js"
    }
  }
}

The exports field gives you conditional resolution — different files for different contexts. When present, it also encapsulates your package: consumers can only import paths you explicitly define.

exports takes priority over main

When both main and exports are present, exports wins in all Node.js versions that support it (12.11+). The main field serves as a fallback for older Node.js versions and some older bundlers.

Conditional Exports

The core power of exports is conditional resolution. Each condition is checked in order, and the first matching one wins:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.cjs",
      "default": "./dist/esm/index.js"
    }
  }
}

The conditions Node.js supports:

// "import"   → used when loaded via import or import()
// "require"  → used when loaded via require()
// "node"     → used when running in Node.js
// "default"  → fallback, always matches (must be last)
// "types"    → used by TypeScript for type resolution (must be first)
// "browser"  → used by bundlers for browser builds (convention)
Common Trap

Order matters. Conditions are checked top to bottom, and the first match wins. "types" must come before "import" and "require" because TypeScript needs to resolve types before anything else. "default" must be last because it matches everything — any condition after it is unreachable.

Quiz
In the exports field, if you put default before import, what happens when an ESM consumer imports the package?

The type Field — Setting Module System Defaults

The type field in package.json tells Node.js how to interpret .js files:

{
  "type": "module"
}
type value.js files areCJS files needESM files need
"module"ES Modules.cjs extension.js works
"commonjs" (or absent)CommonJS.js works.mjs extension
// With "type": "module" in package.json:
// math.js    → treated as ESM (import/export syntax)
// math.cjs   → treated as CommonJS (require/module.exports)
// math.mjs   → treated as ESM (always, regardless of type field)

// With "type": "commonjs" (or no type field):
// math.js    → treated as CommonJS
// math.mjs   → treated as ESM (always)
// math.cjs   → treated as CommonJS (always)
The .mjs and .cjs extensions always win

Regardless of the type field, .mjs is always ESM and .cjs is always CommonJS. These extensions are the "escape hatch" when you need a file to be treated differently from the package default. Many dual packages use this to ship both formats without needing separate directories with their own package.json files.

Subpath Exports — Controlling the Public API

Without exports, consumers can import any file in your package:

// Without exports — consumers can reach into internals
import { helper } from 'my-package/dist/internal/utils.js';
// This works, but you never intended it to be public API

With exports, you define exactly what's accessible:

{
  "exports": {
    ".": "./dist/index.js",
    "./utils": "./dist/utils.js",
    "./math": "./dist/math.js"
  }
}
// Now consumers can do:
import { something } from 'my-package';         // maps to ./dist/index.js
import { helper } from 'my-package/utils';      // maps to ./dist/utils.js
import { add } from 'my-package/math';           // maps to ./dist/math.js

// But this is blocked:
import { internal } from 'my-package/dist/internal/utils.js';
// ERR_PACKAGE_PATH_NOT_EXPORTED

This encapsulation is powerful. You can rename internal files, restructure directories, and refactor freely — as long as the subpath exports still map to the right code, consumers are unaffected.

Subpath patterns — wildcards for large APIs

For packages with many entry points (like icon libraries or locale files), you can use wildcard patterns:

{
  "exports": {
    ".": "./dist/index.js",
    "./icons/*": "./dist/icons/*.js",
    "./locales/*": "./dist/locales/*.js"
  }
}
import { ArrowIcon } from 'my-package/icons/arrow';
import en from 'my-package/locales/en';
Quiz
What does the exports field in package.json provide that the main field does not?

Dual Package Patterns

There are two main approaches to shipping both CJS and ESM from one package.

Pattern 1: Isolated builds (separate files)

Ship both CJS and ESM builds, use exports to route consumers:

{
  "name": "my-package",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.cjs",
      "default": "./dist/esm/index.js"
    }
  }
}

This is the most common pattern. The downside is the dual module hazard — if both the CJS and ESM versions are loaded in the same process, you get two instances.

Pattern 2: CJS wrapper (single source of truth)

Ship ESM as the real implementation, with a thin CJS wrapper that re-exports it:

// dist/index.js (ESM — the real implementation)
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
// dist/index.cjs (CJS wrapper)
module.exports = require('./esm-loader.cjs');
// or using a pattern that lazily loads the ESM version

Actually, this is tricky because require() can't load ESM directly. The real CJS wrapper pattern usually involves building a CJS version from the same source, or using a tool that generates the wrapper automatically.

The most robust approach in 2024+ is the ESM-only approach:

Pattern 3: ESM-only (the future)

{
  "name": "my-package",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  }
}

CJS consumers use dynamic import(). This avoids the dual module hazard entirely, simplifies your build, and is the direction the ecosystem is heading. Major packages like chalk, got, execa, p-*, and globby went ESM-only years ago.

The ESM-only approach was controversial when Sindre Sorhus pushed it in 2021, but it's become mainstream. The key insight is that every CommonJS project can use await import() to load ESM — it's inconvenient but it works. Going ESM-only eliminates the dual hazard, reduces package size, enables reliable tree shaking, and simplifies maintenance. The ecosystem has largely caught up.

Complete package.json Example

Here's a production-ready package.json for a dual package:

{
  "name": "@myorg/utils",
  "version": "2.0.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "default": "./dist/index.js"
    },
    "./math": {
      "types": "./dist/math.d.ts",
      "import": "./dist/math.js",
      "require": "./dist/math.cjs",
      "default": "./dist/math.js"
    },
    "./package.json": "./package.json"
  },
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "files": ["dist"],
  "sideEffects": false
}

Let's break down each field:

Execution Trace
type: module
.js files default to ESM
CJS files must use .cjs extension
exports
Conditional entry points for CJS, ESM, and TypeScript
Takes priority over main/module/types
types condition
TypeScript resolver gets .d.ts files
Must be first in condition order
main
Fallback for old Node.js and old bundlers
Points to CJS for maximum compat
module
Bundler convention for ESM entry point
Not a Node.js standard, but Vite/webpack/Rollup use it
types (top-level)
Fallback for TypeScript without exports support
Older TypeScript versions use this
sideEffects: false
Tells bundlers every file is safe to tree-shake
Critical for bundle size
Quiz
Why is ./package.json listed as a subpath export?

Common Build Tools for Dual Packages

You don't have to hand-craft dual builds. These tools handle it:

tsup — the simplest option for TypeScript packages:

// tsup.config.ts
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['cjs', 'esm'],
  dts: true,
  splitting: false,
  clean: true,
});

Vite library mode — for packages that are part of a Vite project:

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    lib: {
      entry: 'src/index.ts',
      formats: ['es', 'cjs'],
    },
  },
});

unbuild — used by the Nuxt ecosystem:

// build.config.ts
import { defineBuildConfig } from 'unbuild';

export default defineBuildConfig({
  entries: ['src/index'],
  rollup: { emitCJS: true },
  declaration: true,
});
What developers doWhat they should do
Putting `default` before `import` or `require` in the exports conditions
Conditions are checked top to bottom. default matches everything, so anything after it is unreachable.
Order conditions: types first, then import/require, then default last
Forgetting to include `./package.json` in exports
The exports field encapsulates the package. Some tools need to read package.json directly and will fail without this export.
Add `"./package.json": "./package.json"` to exports
Using `module` field without `exports` for ESM entry
The `module` field is a bundler convention, not a Node.js standard. Only `exports` with the `import` condition is recognized by Node.js for ESM resolution.
Use the `exports` field with `import` and `require` conditions
Key Rules
  1. 1The exports field provides conditional resolution, subpath exports, and package encapsulation
  2. 2Condition order matters: types first, then import/require, then default last
  3. 3The type field determines how .js files are interpreted — module for ESM, commonjs for CJS
  4. 4.mjs is always ESM and .cjs is always CJS, regardless of the type field
  5. 5For new packages, consider ESM-only — CJS consumers can use dynamic import()
  6. 6sideEffects: false is critical for tree shaking — always include it if your package has no side effects