Things start getting confusing when your npm package wants to support both ECMAScript Modules (ESM) and CommonJS (CJS), which makes it a dual-package.
Assume we have a package whose file structure looks like this:
./
├── src/
│ ├── shared/
│ │ └── index.ts
│ ├── node/
│ │ ├── cli.ts
│ │ ├── index.cts
│ │ └── index.ts
│ └── browser/
│ ├── pangu.ts
│ └── pangu.umd.ts
├── dist/
│ ├── shared/
│ │ └── index.js
│ ├── node/
│ │ ├── cli.js
│ │ ├── index.cjs
│ │ └── index.js
│ └── browser/
│ ├── pangu.js
│ └── pangu.umd.js
└── package.json
Entry Points
Let's start from package.json. Here is a simplified one from pangu.js:
{
"name": "pangu",
...
"type": "module",
"exports": {
".": {
"import": {
"types": "./dist/node/index.d.ts",
"default": "./dist/node/index.js"
},
"require": {
"types": "./dist/node/index.d.cts",
"default": "./dist/node/index.cjs"
}
}
},
...
}
Simply speaking, the entry points of a package are (usually) defined in package.json's exports field:
import xxx from "pangu"matches the"import"branch, which loads./dist/node/index.js=> ESMrequire("pangu")matches the"require"branch, which loads./dist/node/index.cjs=> CommonJS
Condition Matching Order
In the above package.json, you may have noticed that every branch puts "types" first. That's not a style choice.
Within the exports object, key order is significant. During condition matching, the resolver (Node.js, TypeScript, or any bundler) walks the object from top to bottom, takes the first key it recognizes, then stops. Everything below the winner is ignored.
"types"must go first, so that TypeScript always sees your.d.ts"default"must go last. Anything you list after it is unreachable
ref:
https://nodejs.org/api/packages.html#conditional-exports
https://www.typescriptlang.org/docs/handbook/modules/reference.html#packagejson-exports
ECMAScript Modules (ESM)
ESM is the current standard module system in the JavaScript specification. You should really consider using it if you're not.
A piece of code is an ECMAScript module if any of the following is true:
- A file ends with
.mjsor.mts - A file ends with
.jsor.tsANDpackage.jsonhas"type": "module"- When
tsconfig.jsonsets"module": "esnext"or"moduleResolution": "bundler", every.tsfile is treated as ESM no matter what"type"says
- When
- Inside a
<script type="module">tag
// export
export class NodePangu {}
export const pangu = new NodePangu();
export default pangu;
// import from a file (inside the same package)
import { Pangu } from '../shared/index.js'; // relative imports need the extension in ESM, and you write .js even though the source file is index.ts
// import from a package
import pangu from 'pangu'; // it loads ./dist/node/index.js according to package.json["exports"]["."]["import"]["default"]
import { pangu, NodePangu } from 'pangu';
The mapping:
export default pangu;=>import pangu from 'pangu';export const pangu = new NodePangu();=>import { pangu } from 'pangu';export class NodePangu {}=>import { NodePangu } from 'pangu';
ref:
https://www.typescriptlang.org/docs/handbook/2/modules.html
https://nodejs.org/api/esm.html
Subpath Exports
You just define an extra key in exports, ./browser in our case:
{
"name": "pangu",
...
"type": "module",
"exports": {
".": {
...
},
"./browser": {
"import": {
"types": "./dist/browser/pangu.d.ts",
"default": "./dist/browser/pangu.js"
}
}
},
...
}
The export part is the same, but you will need to add the subpath (/browser) when importing.
// export
export class BrowserPangu {}
export const pangu = new BrowserPangu();
export default pangu;
// import from a package
import pangu from 'pangu/browser'; // it loads ./dist/browser/pangu.js according to package.json["exports"]["./browser"]["import"]["default"]
import { pangu, BrowserPangu } from 'pangu/browser';
It's worth noting that in ESM, only relative imports must include the file extension (.js, .cjs, or .mjs), because they are resolved as plain URLs with no extension guessing. Package names like pangu and pangu/browser don't need one, since they are resolved through the exports field instead of the filesystem.
CommonJS
CommonJS is an old but still widely used module system, especially in the Node.js ecosystem.
A piece of code is a CommonJS module if any of the following is true:
- A file ends with
.cjsor.cts - A file ends with
.jsor.tsANDpackage.jsonhas"type": "commonjs"(or has no"type"field)
A plain <script> tag is not on the list. It's a classic script, not a CommonJS module. Browsers have never supported CommonJS natively, so require() only works on the web after a bundler rewrites it.
class NodePangu {}
const pangu = new NodePangu();
// export
module.exports = pangu;
module.exports.NodePangu = NodePangu;
// import from a file (inside the same package)
const { Pangu } = require('../shared/index'); // the extension is optional in CommonJS
// import from a package
const pangu = require('pangu'); // it loads ./dist/node/index.cjs according to package.json["exports"]["."]["require"]["default"]
const { NodePangu } = require('pangu');
There is also a variable named exports in CommonJS, which is basically an alias of module.exports. Assigning to exports only rebinds that local variable, and what actually gets exported is still whatever module.exports points to.
// You can think of it like this in every CommonJS module
var exports = module.exports = {};
The rule of thumb: just use module.exports, never use exports.
ref:
https://nodejs.org/api/modules.html
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules