DEV Community

Cover image for The package.json exports Map Is the Most Important File You're Writing Wrong
Gabriel Anhaia
Gabriel Anhaia

Posted on

The package.json exports Map Is the Most Important File You're Writing Wrong


Picture the morning after a 1.0 cut. A library that sat at 0.x for a couple of years finally ships its first stable. Release notes are short, the publish was clean, and the maintainer went to bed.

By breakfast the GitHub issues page is full. Hypothetical but typical: a consumer reports that @you/lib/parser cannot be found or has no type declarations. Another hits ERR_REQUIRE_ESM after upgrading. A third sees types resolve to any under moduleResolution: "node16". A fourth finds the subpath import broken in their Jest setup.

The fix is one file. Not the source. The package.json. Specifically the exports map. Two problems in fewer than thirty lines of JSON: the types condition is below import, and the dual-mode subpath has no require condition. npm publishes all of it without flagging a thing.

This is the most common shape of a 1.0 disaster for a TypeScript library in 2026. Tests pass, the build is clean, the local install resolves. Then a thousand consumers each in a slightly different environment hit your published artifact, and the cracks show up everywhere at once. The exports map is the smallest file in your repo and the most expensive one to get wrong.

What exports actually does

Before exports, a Node package had three resolution fields: main for CommonJS, module for ESM (an unofficial convention bundlers picked up), and types for TypeScript. Three independent fields, three ways to be inconsistent.

The exports field, documented in the Node.js docs, unifies all of that. It is the single source of truth for what a consumer can import and which file they get. When exports is present, modern resolvers ignore main and module for paths covered by exports, and only fall back to them for paths the map does not list.

The minimal shape, for a single-entry ESM-only library:

{
  "name": "@you/lib",
  "type": "module",
  "exports": "./dist/index.js"
}
Enter fullscreen mode Exit fullscreen mode

One string. The package can be imported only as @you/lib. Any deeper path is blocked. That blocking is deliberate; the deep-imports section explains the payoff.

The conditional block

Conditions are how exports answers the question "who is asking?". The same package can hand a CJS consumer one file, an ESM consumer another, and a TypeScript compiler a third. Each consumer matches a condition, the resolver walks the conditions in the order you wrote them, and the first match wins.

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "default": "./dist/index.js"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

What each line does:

  • "." is the root entry — what consumers get when they import the bare package name.
  • types points at the declaration file. The TypeScript compiler walks the conditions to find this entry and uses whatever it points at as the type source.
  • import is for ESM consumers. Anyone who writes import x from "@you/lib" and whose module format resolves to ESM lands here.
  • require is for CJS consumers. Node's resolver treats import and require as mutually exclusive — exactly one fires for any given import site.
  • default is the fallback. It must be last. Anything you place after default is unreachable, because default always matches.

The order matters more than most maintainers realize.

The types condition trap

Here is a package.json that ships dual ESM and CJS, includes type definitions, and is broken.

{
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

It is broken because types is last. The TypeScript handbook (modules reference) is unambiguous on this: the resolver walks conditions top-down and takes the first match. With types placed after import, an ESM consumer hits import first, and the resolver returns the JavaScript file as the type source. There is no .d.ts next to it that the resolver tries automatically. TypeScript ends up with any for every export.

The rule is one sentence. types must come first in every conditional block. Always. Inside the root entry, inside every subpath, inside every nested condition. First.

The corrected order:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "default": "./dist/index.js"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

It looks small. It is small. It is also among the most common bugs in published TypeScript packages, because several popular library templates emitted the wrong order by default for years and maintainers copy-pasted what their tool produced. Modern versions have corrected the default. Your existing repos, the ones you have not touched since, almost certainly have not.

Dual ESM/CJS, the right way

A library that wants to support both module systems needs two physical files: an .mjs (or .js with "type": "module") for ESM and a .cjs for CJS. They cannot be the same file. CommonJS and ECMAScript modules have different module-system semantics, and Node decides which loader to use based on the file extension and the nearest package.json's "type" field.

The full shape:

{
  "name": "@you/lib",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "default": "./dist/index.js"
    }
  },
  "files": ["dist"]
}
Enter fullscreen mode Exit fullscreen mode

"type": "module" at the top says any .js file in this package is ESM. The .cjs extension overrides that for the CommonJS build. The .d.ts is consumed by both, because TypeScript has its own resolution pass and does not care about the runtime split here.

If you want type definitions that match each runtime exactly — and you should, for any library that exports anything more interesting than functions — you ship two declaration files: .d.ts for ESM, .d.cts for CJS. The shape becomes nested:

{
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

types is still first inside each nested block. The outer block walks import vs require first, picks the right runtime, and only then resolves types and default for that runtime. This is the shape attw will tell you to use the moment you ship a generic at the package boundary that resolves differently in CJS and ESM.

Subpath exports and the * pattern

Most libraries ship more than one entry point. A logging library has core and transports. A UI kit has each component. A data library has parser, formatter, validator. Each entry point gets its own subpath in the map.

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "default": "./dist/index.js"
    },
    "./parser": {
      "types": "./dist/parser.d.ts",
      "import": "./dist/parser.js",
      "require": "./dist/parser.cjs",
      "default": "./dist/parser.js"
    },
    "./package.json": "./package.json"
  }
}
Enter fullscreen mode Exit fullscreen mode

Three entries. The root, the ./parser subpath, and an explicit unblock for ./package.json because some tools still read the package manifest at install time and need that path open.

For libraries with many similar subpaths, you can use the wildcard pattern:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./features/*": {
      "types": "./dist/features/*.d.ts",
      "import": "./dist/features/*.js"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The * is plain string substitution, not a glob. @you/lib/features/parser matches ./dist/features/parser.js and ./dist/features/parser.d.ts. The substitution applies on both sides. You can use it to expose a directory tree without listing every file, and you can pair it with null to block specific paths inside the wildcard:

{
  "exports": {
    "./features/*": "./dist/features/*.js",
    "./features/internal/*": null
  }
}
Enter fullscreen mode Exit fullscreen mode

The null makes any import of @you/lib/features/internal/anything resolve to nothing. The package will refuse to load it. This is the deep-imports knob.

Deep imports, blocked by default

This is the part that surprises people coming from a pre-exports world. When exports is present, every path not listed in the map is blocked, including paths that exist on disk inside the published package.

A consumer who tries:

import { internal } from "@you/lib/dist/internal/util";
Enter fullscreen mode Exit fullscreen mode

…gets ERR_PACKAGE_PATH_NOT_EXPORTED, even though the file is sitting right there in node_modules/@you/lib/dist/internal/util.js. The exports map said no.

It lets you treat dist/ as a private build artifact and your exports map as the public API. You can refactor dist/internal/util.js into seven different files across two directories, and as long as the listed entries in exports keep the same shape, no consumer breaks.

It is also the source of half the migration pain when an existing library adopts exports for the first time. Consumers who were reaching into lib/utils/something for years suddenly cannot. The fix is either to expose those paths explicitly through the map or, more often, to publish a major version bump and tell consumers to use the public surface.

The corollary: the moment you add an exports field to a library that did not have one, you need to know every subpath any consumer might use. attw and publint will help with the published-package side. The consumer side you find by searching GitHub for from "@you/lib/.

attw: are the types wrong

@arethetypeswrong/cli, called attw, is the audit tool for the published-package shape. It runs the published artifact through every resolution mode TypeScript supports and reports which ones return correct types.

npm pack
npx @arethetypeswrong/cli --pack
Enter fullscreen mode Exit fullscreen mode

The --pack flag points attw at the tarball npm pack produced. That tarball is the bytes that go to the registry, which is what consumers download, which is what you need to audit. Auditing the source tree audits something close but not identical, and the difference is exactly where bugs live.

attw documents twelve problem categories. The ones you hit most:

  • Resolution failed. TypeScript cannot find a .d.ts at all under some resolution mode. Usually because types is missing from a conditional block or points at a path not in the tarball.
  • Masquerading as CJS. The require condition points at a file the runtime treats as ESM. The require() succeeds at type-check time, then crashes at runtime with ERR_REQUIRE_ESM.
  • Masquerading as ESM. The reverse. The import condition points at a file Node treats as CJS. The default-export shape changes, and consumers get the wrong default.
  • Fallback condition. The resolver fell back to a condition you did not intend, usually because a more specific one was missing.
  • CJS default export / named exports. The CJS build's interop shape does not match what TypeScript declared.

The output is a matrix: rows are resolution modes (node10, node16 CJS and ESM, bundler), columns are entry points, cells are pass/fail. A clean package is all green. A broken one shows exactly which combination of resolution mode and entry point fails, which is the information you need to fix it.

publint: the manifest auditor

publint is attw's sibling. Where attw checks types, publint checks the package.json itself: condition order, missing fields, deprecated patterns, files that are listed but not present in the tarball, files that are present but not exported.

npx publint
Enter fullscreen mode Exit fullscreen mode

The warnings it produces against a typical broken library:

pkg.exports["."] should have "types" condition first
pkg.exports["./parser"] is missing "require" condition
pkg.main is set, but pkg.exports is also set
  (modern resolvers ignore main; remove it)
pkg.types is set, but pkg.exports already provides
  the types condition; remove pkg.types
pkg.exports["."] should have "default" condition last
Enter fullscreen mode Exit fullscreen mode

(Sample output. Exact wording varies by publint version. Run it against your own package.)

Every line is a specific, fixable, named problem. The legacy fields create a parallel resolution path that drifts from exports. The missing condition is a runtime crash for the affected consumer. The order issue is the types-trap. None of these surface in npm publish's output, because npm does not validate the exports map structurally; it only checks that the file paths exist.

The standard CI shape for any TypeScript library shipped in 2026:

- run: npm pack
- run: npx publint
- run: npx @arethetypeswrong/cli --pack
Enter fullscreen mode Exit fullscreen mode

Three commands. They run in seconds. They catch the entire class of exports-map bugs the title of this post is about. There is no good reason a serious library does not have all three of them in prepublishOnly or in CI before the publish step.

The decision tree: ESM-only, dual, CJS-only, JSR

The temptation when starting a new library is to ship every format and let the consumer pick. Each format is another exports row to maintain, another build target, another shape attw audits against. Most libraries pick wrong by reflex.

ESM-only is the right default for new libraries in 2026. Node has supported ESM as a first-class citizen for years. Bun, Deno, the browser, every modern bundler — all ESM-first. The only consumer base that genuinely cannot upgrade is one running CommonJS-only Node code on long-LTS versions that has not been touched in three years. If your library does not need that audience, do not pay for it.

Dual ESM/CJS is correct for libraries with a large existing CJS consumer base, or for ecosystem libraries (test frameworks, build tools, common utilities) where breaking CJS would shed users. The cost is real: every release pays the dual-format tax. Pick this consciously.

CJS-only is correct only for libraries pinned to a tool that is itself CJS-only. New libraries should not start here.

JSR alongside npm is the move when the library is runtime-agnostic and pure logic — types, parsing, hashing, validation, formatting. JSR's slow-types restriction surfaces boundary-annotation issues you would have benefited from anyway, and it gives you a Deno-native distribution channel.

The decision is not "which format is best." It is "which formats do my consumers need, and what am I willing to pay to support them?" Most libraries should answer ESM-only, run attw and publint in CI, and move on.

What to do tomorrow

Open the package.json of the most-installed library you maintain. Look at the exports field.

Is types first in every conditional block? If not, fix that today and ship a patch. The fix is a JSON re-order, no code changes.

Does every subpath that has an import condition also have a require condition, if your package ships CJS? If not, either remove the CJS build or add the missing condition. A subpath that is import-only in a dual-format package is a runtime crash waiting for the first CJS consumer to deep-import.

Is attw --pack and publint in your CI? If not, add the three lines. Fifteen minutes of work, and you stop shipping exports-map bugs at the same speed you used to.

The reason to fix this is not theoretical. It is the GitHub thread your future self does not want to wake up to.


If this was useful

exports-map design, attw and publint in CI, dual-publish patterns, and the tsconfig choices that decide how all of this resolves are the focus of TypeScript in Production, the production layer of The TypeScript Library. The other four books in the collection cover the path you take to get there.

The reader-journey rule for the collection: pick books 1 and 2 if TypeScript is your first serious typed language. Substitute in 3 or 4 if you are bridging from JVM or PHP. Add book 5, TypeScript in Production, once you are shipping TS at work — which is exactly the moment your exports map starts deciding whether your library survives its 1.0.

  • TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point. Types, narrowing, modules, async, daily-driver tooling. Amazon
  • The TypeScript Type System — From Generics to DSL-Level Types — deep dive. Generics, mapped/conditional types, infer, template literals, branded types. Amazon
  • Kotlin and Java to TypeScript — A Bridge for JVM Developers — variance, null safety, sealed→unions, coroutines→async/await. Amazon
  • PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — sync→async paradigm, generics, discriminated unions. Amazon
  • TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR. Amazon

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)