In a package.json, some fields define where to find what.
mainis the main entry point in CJS; or fortype: "module"packages to ESMmoduleis only used by bundlers to resolve ESM, for tree-shaking for CJS+ESM packages, not by Node.jstypesis only used by TS to resolve typesexportsis only used by modern Node.js, if a package istype: "module"; patterns are only supported since v14.13.0, v12.20.0
For
Node/Node10typescriptmoduleResolutiontheexportsis not used for typing resolving, heretypescan serve as compatibility fallback for that level. Yet it seemsexportsis 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/typesAND/ORexportswould 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.
mainpoints to relative.js/.cjsfile (CJS)modulepoints to a different file, for sub-paths can be in a parent directory (ESM)- could also be implemented with
.mjsfile 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.
mainpoints to a different file, for sub-paths can be in a parent directory (CJS)modulepoints to relative.js/.mjsfile (ESM)- could also be implemented with
.cjsfile 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:
typespoints to.d.tsor.tsimportpoints to ESMrequirefallback 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,/typesdirectories 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/moduleentries, 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
maininpackage.json, does not followexports
- old CJS with some basic ESM support, but not
NodeNext/Node16- strict ESM, needs
.cjsfor own CJS - requires
.js/.cjs/.mjsfile extension at imports - supports
package.jsonexports, and seems likemain/typesto 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.jsonexports, and seems likemain/typesto 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 withNode16module 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 (
importandrequire) is provided by definingimport(for ES modules) andrequire(for CommonJS) paths separately. -
.– Resolvesimport { ComponentA } from 'lorem'tolorem/index.jsfor ES modules andlorem/index.cjsfor CommonJS. -
"./*"– Maps subpaths likelorem/ComponentAtolorem/ComponentA/index.js(orindex.cjsfor 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
ComponentAfrom the root package:import { ComponentA } from 'lorem':- The root’s
exportsdefinition resolves this toindex.jsif using ES modules orindex.cjsif using CommonJS.
- The root’s
- Importing
ComponentAvia subpathlorem/ComponentA:import { ComponentA } from 'lorem/ComponentA':- The subpath
exports("./*": {...}) in the rootpackage.jsontakes precedence. It will resolve tolorem/ComponentA/index.jsfor ES modules orindex.cjsfor CommonJS, regardless ofComponentA’s localexportsfield.
- The subpath
- Local
exportsinComponentAis ignored because Node.js does not traverse nestedpackage.jsonfiles when resolving subpaths. Instead, it uses the root’sexportsmappings. - This means:
- Types:
typesin the root’sexports("./*": {...}) directs TypeScript to look forindex.d.tswithinComponentA. - Modules: Node.js respects the root
exportsfor 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
typeorexportsare removed inComponentA, the rootexportsstill handles module resolution. - However, local
exportsinComponentAcould 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_modulesfor backwards compatibility, depending on existence oftype: "module"inpackage.jsonof the package that is imported
todo: describe also
node,browserand suchpackage.json, do they behave likemain/module- and are bundlers only?
todo: describe the
index.jsresolving behaviour for directory imports, which is not supported in strict-ESMtype: "module"without absolute paths
todo: for strict-ESM check how and if root
exportsworks together with nestedpackage.jsonand furtherexportsin them.nested
package.jsonwithexportsdon'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.jsonexports 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.