Package entry points: exports and types

npm · Advanced · 15 min

The exports map controls what consumers can import, and mistakes break bundlers, Node and TypeScript differently.

The exports field in package.json defines a package's public entry points. Once present, consumers can import only the listed paths; deep imports like pkg/dist/internal.js fail with ERR_PACKAGE_PATH_NOT_EXPORTED. That encapsulation lets you refactor internals freely.

"exports": {
  ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" },
  "./utils": "./dist/utils.js",
  "./package.json": "./package.json"
}

Conditions are matched in object order, first match wins: types (TypeScript), import, require, node, browser, development/production, and default as the fallback. Put types first and default last.

Related fields:

  • main and module are the legacy entry points; keep main only for old tools.
  • type: "module" makes .js files ESM; .cjs and .mjs override per file.
  • imports defines private aliases starting with #, like "#db": "./src/db/index.js", usable only inside the package.
  • sideEffects: false tells bundlers unused modules can be dropped entirely.

Dual ESM/CommonJS packages risk the dual package hazard: two copies of module state loaded at once. Since Node 22 can require() ESM, publishing ESM-only is increasingly viable. Check your package with publint and @arethetypeswrong/cli.

Example

{
  "name": "@ann/slugify",
  "version": "2.0.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./locales/*": "./dist/locales/*.js",
    "./package.json": "./package.json"
  },
  "imports": {
    "#internal/*": "./src/internal/*.js"
  },
  "main": "./dist/index.cjs",
  "sideEffects": false,
  "files": ["dist"]
}
// import { slugify } from "@ann/slugify"          OK
// import de from "@ann/slugify/locales/de"        OK
// import x from "@ann/slugify/dist/index.js"      ERR_PACKAGE_PATH_NOT_EXPORTED

Remember

  • exports defines the only importable paths
  • Conditions match in order: types first, default last
  • imports gives private # aliases inside the package
  • Validate packages with publint and arethetypeswrong

Watch out for

  • Putting types after import in a condition object, so TypeScript never sees it.

Check yourself

  1. A package has an `exports` map without `./dist/*`. What happens on `import 'pkg/dist/a.js'`?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.npmjs.com/cli/v11/configuring-npm/package-json#exports