Skip to content

Instantly share code, notes, and snippets.

@elbakerino
Last active November 22, 2024 15:33
Show Gist options
  • Save elbakerino/a05b6148d78edbd20eff30ce81ac273c to your computer and use it in GitHub Desktop.
Save elbakerino/a05b6148d78edbd20eff30ce81ac273c to your computer and use it in GitHub Desktop.
ESM Package Exports

ESM, CJS and moduleResolution

main, module, types, exports

In a package.json, some fields define where to find what.

  • main is the main entry point in CJS; or for type: "module" packages to ESM
  • module is only used by bundlers to resolve ESM, for tree-shaking for CJS+ESM packages, not by Node.js
  • types is only used by TS to resolve types
  • exports is only used by modern Node.js, if a package is type: "module"; patterns are only supported since v14.13.0, v12.20.0

For Node/Node10 typescript moduleResolution the exports is not used for typing resolving, here types can serve as compatibility fallback for that level. Yet it seems exports is still used by (either babel/webpack/typescript) to still lookup the entry points.

Node.js spec.: main, exports, imports, type

Structure Algorithm

This only applies for modular packages, for single file projects main/module/types AND/OR exports 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.

Legacy / Node10

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.

CJS first

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"
}

ESM first

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"
}

Strict ESM / Node16 / Bundler

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:

  1. types points to .d.ts or .ts
  2. import points to ESM
  3. require 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.

Package type: "module", no exports

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'

Behaviour Of Project Imports Package

Project Node10, package type: "module"

  • typescript won't follow conditional exports, thus can only resolve types if structure like in legacy algorithm

Project Node10, package no type: "module" but ESM

  • typescript won't follow conditional exports, thus can only resolve types if structure like in legacy algorithm

Project Node16

  • 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 CJS exports, 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.

Project Bundler

  • 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?

TS moduleResolution

  • Node/Node10
    • old CJS with some basic ESM support, but not type: "module"
    • only supports const x = require('x') and not import x from "x"
      • except through dynamic import('x').then(m => m.default)
    • supports main in package.json, does not follow exports
  • NodeNext/Node16
    • strict ESM, needs .cjs for own CJS
    • requires .js/.cjs/.mjs file extension at imports
    • supports package.json exports, and seems like main/types to some extend
    • importing pure-ESM packages from projects requires type: "module" in the project
  • Bundler - modern ESM
    • not strict ESM, can be used in CJS projects
    • file extension at import are optional
    • supports package.json exports, and seems like main/types to some extend
    • importing pure-ESM packages from projects does not require type: "module" in the project

exports Cheatsheet

Examples from Node.js conditional exports.

All paths defined in the "exports" must be relative file URLs starting with ./

Node.js exports

Single File Conditional Exports

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"
  }
} 

Multiple File Conditional Exports

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"
      }
    }
  }
}

Example Behaviour with nested package.json

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 nested package.json. Also it overlaps with the Node.js behaviour I've observed in type: "module" projects with Node16 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'

Root package.json with exports and type: "module"

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 and require) is provided by defining import (for ES modules) and require (for CommonJS) paths separately.

  • . – Resolves import { ComponentA } from 'lorem' to lorem/index.js for ES modules and lorem/index.cjs for CommonJS.

  • "./*" – Maps subpaths like lorem/ComponentA to lorem/ComponentA/index.js (or index.cjs for CommonJS).

Nested package.json inside ComponentA

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.

Behavior with Different Imports

  1. Importing ComponentA from the root package:
    • import { ComponentA } from 'lorem':
      • The root’s exports definition resolves this to index.js if using ES modules or index.cjs if using CommonJS.
  2. Importing ComponentA via subpath lorem/ComponentA:
    • import { ComponentA } from 'lorem/ComponentA':
      • The subpath exports ("./*": {...}) in the root package.json takes precedence. It will resolve to lorem/ComponentA/index.js for ES modules or index.cjs for CommonJS, regardless of ComponentA’s local exports field.

Impact of Nested package.json on Imports

  • Local exports in ComponentA is ignored because Node.js does not traverse nested package.json files when resolving subpaths. Instead, it uses the root’s exports mappings.
  • This means:
    • Types: types in the root’s exports ("./*": {...}) directs TypeScript to look for index.d.ts within ComponentA.
    • Modules: Node.js respects the root exports for resolving paths like lorem/ComponentA.

Variations in Nested package.json

Changing the nested package.json for ComponentA would not impact the resolution if the root exports already maps lorem/ComponentA:

  • If type or exports are removed in ComponentA, the root exports still handles module resolution.
  • However, local exports in ComponentA 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 of type: "module" in package.json of the package that is imported

todo: describe also node, browser and such package.json, do they behave like main/module - and are bundlers only?

todo: describe the index.js resolving behaviour for directory imports, which is not supported in strict-ESM type: "module" without absolute paths

todo: for strict-ESM check how and if root exports works together with nested package.json and further exports in them.

nested package.json with exports don't work with different folders, only with relative files, thus with the file-extension strategy .cjs - or nested environment directories inside the esm folder

from one project it seems nested package.json exports are not used if the root defines exports, as the nested where invalid yet it worked without issues, but all projects where on moduleResolution Node10 at that moment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment