In a package.json
, some fields define where to find what.
main
is the main entry point in CJS; or fortype: "module"
packages to ESMmodule
is only used by bundlers to resolve ESM, for tree-shaking for CJS+ESM packages, not by Node.jstypes
is only used by TS to resolve typesexports
is only used by modern Node.js, if a package istype: "module"
; patterns are only supported since v14.13.0, v12.20.0
For
Node
/Node10
typescriptmoduleResolution
theexports
is not used for typing resolving, heretypes
can serve as compatibility fallback for that level. Yet it seemsexports
is still used by (either babel/webpack/typescript) to still lookup the entry points.
Node.js spec.: main
, exports
, imports
, type
This only applies for modular packages, for single file projects
main
/module
/types
AND/ORexports
would be enough to point to the respective file. YET sub-directories could still lead to some issues depending on project and their type/moduleResolution.
For TS and JS to work, in the published package must be one compiled JS version and respective package.json
"like resolved", without /src
or other sub-directories.
/package.json # main to `./index.js`, module to `./esm/index.js`, types to `./index.d.ts`
/index.js # CJS
/index.d.ts # TS
/ComponentA # directory
/ComponentA/index.js # CJS
/ComponentA/index.d.ts # TS
/ComponentA/ComponentA.js # CJS optional sub file, which is exported from `index.js` in same directory
# must not be imported itself with absolute path, would break `package.json` alias to ESM
# but TS would still work to lookup `ComponentA.d.ts`
/ComponentA/ComponentA.d.ts # TS optional sub file, which is exported from `index.d.ts`
/ComponentA/package.json # needed to point to ESM (or CJS); should include `sideEffects: false`
/esm/index.js # ESM
/esm/ComponentA # directory
/esm/ComponentA/index.js # ESM
/esm/ComponentA/ComponentA.js # ESM optional sub file, which is exported from `index.js` in same directory
/esm/ComponentA/package.json # this file could be needed, if the bundler treats ESM as isolated file or doesn't inherit the setting from the CJS `package.json`, e.g. to specify `sideEffects: false`
For the "ESM first" strategy the CJS and ESM would be swapped.
The JS files in default resolution path are CJS.
main
points to relative.js
/.cjs
file (CJS)module
points to a different file, for sub-paths can be in a parent directory (ESM)- could also be implemented with
.mjs
file extensions in same folder
Root /package.json
{
"sideEffects": false,
"module": "./esm/index.js",
"main": "./index.js",
"types": "./index.d.ts"
}
Nested /ComponentA/package.json
{
"sideEffects": false,
"module": "../esm/ComponentA/index.js",
"main": "./index.js",
"types": "./index.d.ts"
}
The JS files in default resolution path are ESM.
main
points to a different file, for sub-paths can be in a parent directory (CJS)module
points to relative.js
/.mjs
file (ESM)- could also be implemented with
.cjs
file extensions in same folder
Root /package.json
{
"sideEffects": false,
"module": "./index.js",
"main": "./cjs/index.js",
"types": "./index.d.ts"
}
Nested /ComponentA/package.json
{
"sideEffects": false,
"module": "./index.js",
"main": "../cjs/ComponentA/index.js",
"types": "./index.d.ts"
}
For type: "module"
projects and packages.
This allows moving all modular files into sub-directories - but doesn't require it.
This example uses "ESM first", the root package.json
must contain all exports
specifications.
Note
Definition work "first match is used", thus the fallbacks must come last.
For special exports, which targets specific environments, they must come before the more generic exports.
The order must be:
types
points to.d.ts
or.ts
import
points to ESMrequire
fallback that points to CJS
{
"type": "module",
"exports": {
".": {
"types": "./src/index.d.ts",
"import": "./src/index.js",
"require": "./cjs/index.js"
},
"ComponentA": {
"types": "./src/ComponentA/index.d.ts",
"import": "./src/ComponentA/index.js",
"require": "./cjs/ComponentA/index.js"
}
}
}
/package.json # root package.json with `exports` like above and `type: "module"`
/src/index.js # ESM
/src/index.d.ts # TS
/src/ComponentA # directory
/src/ComponentA/index.js # ESM
/src/ComponentA/index.d.ts # TS
/src/ComponentA/ComponentA.js # ESM optional sub file, which is exported from `index.js` in same directory
# can not be imported by absolute path, except if allowed in `exports` of root `package.json`
/src/ComponentA/ComponentA.d.ts # TS optional sub file, which is exported from `index.d.ts`
/src/ComponentA/package.json # needed to point to ESM (or CJS); should include `sideEffects: false`
# optional CJS support
/cjs/index.js # CJS
/cjs/ComponentA # directory
/cjs/ComponentA/index.js # CJS
/cjs/ComponentA/ComponentA.js # CJS optional sub file, which is exported from `index.js` in same directory
This could also have
/esm
,/cjs
,/types
directories to separate the different outputs completely.
It is possible to specify a project as type: "module"
, yet not use exports
.
Example from react-json-tree:
{
"files": [
"lib",
"src"
],
"main": "lib/index.js",
"types": "lib/index.d.ts",
"type": "module"
}
Which can be imported from a type: "module"
/Node16
project like:
// TS and JS works:
import { JSONTree } from 'react-json-tree'
import { JSONTree } from 'react-json-tree/lib'
import { JSONTree } from 'react-json-tree/lib/index.js'
// or even, which resolves to `react-json-tree/src/index.tsx` and thus may be subjected to type checks
import { JSONTree } from 'react-json-tree/src/index.js'
- typescript won't follow conditional exports, thus can only resolve types if structure like in legacy algorithm
- typescript won't follow conditional exports, thus can only resolve types if structure like in legacy algorithm
- typescript can follow conditional exports, thus can resolve types like in strict-ESM algorithm
- requires that the imported package is strict-ESM or that it does not define
type: "module"
and the project adjusts all import paths if ESM, supports CJS imports like legacy - this only works for projects with
type: "module"
- without
type: "module"
the project is CJS and thus can only import packages which provide some CJSexports
, if a package only provides ESM, it will lead to:TS1479: The current file is a CommonJS module whose imports will produce require calls; however, the referenced file is an ECMAScript module and cannot be imported with require.
- without
- typescript can follow conditional exports, thus can resolve types like in strict-ESM algorithm
- this works for projects with or without
type: "module"
- TBD: it seems it will follow
main
/module
entries, as older packages still work correctly - or that is as it doesn't require file extensions at imports and thus works for packages in legacy-structure?
Node
/Node10
- old CJS with some basic ESM support, but not
type: "module"
- only supports
const x = require('x')
and notimport x from "x"
- except through dynamic
import('x').then(m => m.default)
- except through dynamic
- supports
main
inpackage.json
, does not followexports
- old CJS with some basic ESM support, but not
NodeNext
/Node16
- strict ESM, needs
.cjs
for own CJS - requires
.js
/.cjs
/.mjs
file extension at imports - supports
package.json
exports
, and seems likemain
/types
to some extend - importing pure-ESM packages from projects requires
type: "module"
in the project
- strict ESM, needs
Bundler
- modern ESM- not strict ESM, can be used in CJS projects
- file extension at import are optional
- supports
package.json
exports
, and seems likemain
/types
to some extend - importing pure-ESM packages from projects does not require
type: "module"
in the project
Examples from Node.js conditional exports.
All paths defined in the "exports" must be relative file URLs starting with ./
If the property does not start with a .
it is a condition:
{
"exports": {
"import": "./index-module.js",
"require": "./index-require.cjs"
},
"type": "module"
}
Which allows nested conditions:
{
"exports": {
"node": {
"import": "./feature-node.mjs",
"require": "./feature-node.cjs"
},
"default": "./feature.mjs"
}
}
If starting with a .
it allows sub-path exports:
{
"exports": {
".": "./index.js",
"./feature.js": {
"node": "./feature-node.js",
"default": "./feature.js"
}
}
}
This allows further nesting, to define conditionals for different consumers based on conditionals of the initial consumer/environment:
{
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
},
"default": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
}
}
The explanation was generated by ChatGPT, based on a specified example.
While GPT often doesn't correctly explain
type
/exports
/main
/module
, it so far overlaps with what I've read in the Node.js spec - but I didn't found any explicit examples there for nestedpackage.json
. Also it overlaps with the Node.js behaviour I've observed intype: "module"
projects withNode16
module resolution.
Example structure in demo package lorem
:
/package.json
/index.js # exports any sub-path `export * from './ComponentA/index.js`
/index.cjs
/ComponentA/package.json
/ComponentA/index.js
/ComponentA/index.cjs
Root package.json
excerpt:
{
"type": "module",
"main": "./index.cjs",
"module": "./index.js",
"types": "./index.d.ts",
"exports": {
".": {
"types": "./index.d.ts",
"import": "./index.js",
"require": "./index.cjs"
},
"./*": {
"types": "./*/index.d.ts",
"import": "./*/index.js",
"require": "./*/index.cjs"
}
}
}
package.json
inside ComponentA:
{
"type": "module",
"main": "./index.cjs",
"module": "./index.js",
"exports": {
"types": "./index.d.ts",
"import": "./index.js",
"require": "./index.cjs"
}
}
Consider these example imports:
import { ComponentA } from 'lorem'
import { ComponentA } from 'lorem/ComponentA'
In the root, the exports
field defines paths for both the package root ("."
) and any subpaths ("./*"
). Since type: "module"
is set, the following applies:
-
All files are interpreted as ES modules unless specified otherwise.
-
Dual-mode support (
import
andrequire
) is provided by definingimport
(for ES modules) andrequire
(for CommonJS) paths separately. -
.
– Resolvesimport { ComponentA } from 'lorem'
tolorem/index.js
for ES modules andlorem/index.cjs
for CommonJS. -
"./*"
– Maps subpaths likelorem/ComponentA
tolorem/ComponentA/index.js
(orindex.cjs
for CommonJS).
The nested package.json
inside ComponentA
also specifies type: "module"
, a main
entry point for CommonJS (index.cjs
), a module
entry point for ES modules (index.js
) and a exports
for it's relative content, which is already exported by the root package.json
.
- Importing
ComponentA
from the root package:import { ComponentA } from 'lorem'
:- The root’s
exports
definition resolves this toindex.js
if using ES modules orindex.cjs
if using CommonJS.
- The root’s
- Importing
ComponentA
via subpathlorem/ComponentA
:import { ComponentA } from 'lorem/ComponentA'
:- The subpath
exports
("./*": {...}
) in the rootpackage.json
takes precedence. It will resolve tolorem/ComponentA/index.js
for ES modules orindex.cjs
for CommonJS, regardless ofComponentA
’s localexports
field.
- The subpath
- Local
exports
inComponentA
is ignored because Node.js does not traverse nestedpackage.json
files when resolving subpaths. Instead, it uses the root’sexports
mappings. - This means:
- Types:
types
in the root’sexports
("./*": {...}
) directs TypeScript to look forindex.d.ts
withinComponentA
. - Modules: Node.js respects the root
exports
for resolving paths likelorem/ComponentA
.
- Types:
Changing the nested package.json
for ComponentA
would not impact the resolution if the root exports
already maps lorem/ComponentA
:
- If
type
orexports
are removed inComponentA
, the rootexports
still handles module resolution. - However, local
exports
inComponentA
could matter when directly importing from it as a standalone module (e.g.,import { ... } from './ComponentA'
within the package).
In summary, Node.js primarily uses the root’s exports
for subpath resolution, while TypeScript’s paths
setup or exports
also adhere to root rules.
While bundlers like webpack will follow
main
/module
, strict implementations like in Jest won't support it and like NodeJS require adding the file extensions.
todo: describe behaviour difference "importing CJS from ESM" vs. "importing ESM from ESM", these seem to be different when importing such from a installed
node_modules
for backwards compatibility, depending on existence oftype: "module"
inpackage.json
of the package that is imported
todo: describe also
node
,browser
and suchpackage.json
, do they behave likemain
/module
- and are bundlers only?
todo: describe the
index.js
resolving behaviour for directory imports, which is not supported in strict-ESMtype: "module"
without absolute paths
todo: for strict-ESM check how and if root
exports
works together with nestedpackage.json
and furtherexports
in them.nested
package.json
withexports
don't work with different folders, only with relative files, thus with the file-extension strategy.cjs
- or nested environment directories inside the esm folderfrom one project it seems nested
package.json
exports are not used if the root definesexports
, as the nested where invalid yet it worked without issues, but all projects where on moduleResolution Node10 at that moment.