The Practical Developer

package.json `exports` Field: The Modern Package Boundary You Are Probably Misconfiguring

Your package.json `exports` field is either missing, wrong, or silently ignored by some consumers and not others. Here is the complete guide to conditional exports, subpath patterns, TypeScript resolution, and the migration path from the old `main` field.

Software developer writing code on a modern laptop with multiple monitors, representing the complexity of package configuration and module resolution

You shipped a library. A user tries to import a utility from @yourorg/lib/internal/helper and gets ERR_PACKAGE_PATH_NOT_EXPORTED. Another user says it works fine with require but breaks with import. A third user, using TypeScript with moduleResolution: "bundler", gets red squiggles everywhere. You check your package.json. The main field points to dist/index.js. The types field points to dist/index.d.ts. You do not have an exports field. And every single one of those errors is your fault.

This post is the practical guide to the exports field in package.json. It is the most important packaging feature Node.js has shipped in years, and most teams configure it wrong for months before they figure out what it actually does. By the end of this post you will know exactly how to set it up for your packages and how to debug the errors it produces when you get it wrong.

The problem exports solves

Before exports, a package.json had a single main field:

{
  "name": "@yourorg/utils",
  "main": "dist/index.js"
}

This told Node.js one thing: when someone writes require("@yourorg/utils") or import ... from "@yourorg/utils", resolve to dist/index.js. But it did nothing to prevent:

  • require("@yourorg/utils/dist/internal/crypto") — accessing an internal module that you intended to keep private
  • require("@yourorg/utils") resolving differently in CJS vs ESM consumers
  • TypeScript finding types from a completely different path than the runtime resolved
  • Build tools guessing which file to bundle when you only meant to expose a subset

The exports field solves all of these by defining an explicit package boundary. If a path is not listed in exports, Node.js refuses to load it. No more accidental surface area. No more “this works in my test runner but not in production” confusion.

The simplest correct exports field

Start with the minimal viable exports:

{
  "name": "@yourorg/utils",
  "exports": {
    ".": "./dist/index.js"
  }
}

This looks similar to main, but the behavior is different. Now require("@yourorg/utils") and import "@yourorg/utils" both resolve to ./dist/index.js, and any attempt to access a path like @yourorg/utils/package.json or @yourorg/utils/dist/something.js will produce a clean ERR_PACKAGE_PATH_NOT_EXPORTED error.

The period key (.) represents the package’s root. Every export path starts with ./ relative to the package root. This is not optional — Node.js enforces the ./ prefix for subpath exports.

If you also want to allow consumers to import package.json (common for version checks), add it explicitly:

{
  "exports": {
    ".": "./dist/index.js",
    "./package.json": "./package.json"
  }
}

Everything else is blocked. No more accidental require("your-package/dist/helpers/secret") that becomes a breaking change when you rename the file.

Subpath exports: exposing a controlled API surface

The real power comes when you want to expose multiple entry points. Instead of making users import deep relative paths into your package internals, you define named subpaths:

{
  "exports": {
    ".": "./dist/index.js",
    "./react": "./dist/react/index.js",
    "./testing": "./dist/testing/index.js",
    "./types": "./dist/types/index.d.ts"
  }
}

Now consumers import like this:

import { parseDate } from "@yourorg/utils"
import { useQuery } from "@yourorg/utils/react"
import { mockHttp } from "@yourorg/utils/testing"

If you move react/index.js to react-components/index.js, you change the one line in exports and every consumer keeps working. The exports field is where your package’s public API surface lives. If it is not in exports, it is private.

A common pattern is the wildcard subpath:

{
  "exports": {
    ".": "./dist/index.js",
    "./internal/*": null
  }
}

This explicitly blocks @yourorg/utils/internal/something and returns a clear error instead of silently resolving to a file path. The null value means “this path is intentionally unavailable.”

Conditional exports: different code for CJS vs ESM

This is where exports becomes indispensable. If your package ships both CommonJS and ESM builds, you need to serve the right format to the right consumer. That is what conditional exports do:

{
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js"
    },
    "./react": {
      "import": "./dist/esm/react/index.js",
      "require": "./dist/cjs/react/index.js"
    }
  }
}

Node.js evaluates conditions in order. When a consumer uses import, the "import" branch wins. When a consumer uses require, the "require" branch wins. If neither matches, the next conditions are checked.

Condition order matters. The spec defines evaluation priority: the most specific conditions come first. The standard condition order is:

  1. "types" — for TypeScript type resolution (more on this below)
  2. "node" — for Node.js-specific code
  3. "import" or "require" — for module system
  4. "default" — fallback for everything else

Here is a realistic example with all four:

{
  "exports": {
    ".": {
      "types": "./dist/types/index.d.ts",
      "node": {
        "import": "./dist/esm/node/index.js",
        "require": "./dist/cjs/node/index.js"
      },
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js"
    }
  }
}

This tells TypeScript where to find types, Node.js where to find Node-specific code (maybe using fs or crypto), and bundlers or browsers where to find the universal build.

The default condition

The default condition is the catch-all. It matches any environment that does not match earlier conditions:

{
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js",
      "default": "./dist/umd/index.js"
    }
  }
}

Put default last. Conditions are evaluated in order, and default should always be the final fallback.

TypeScript and the types condition

TypeScript added support for the exports field in TypeScript 4.7, but there is a gotcha. TypeScript uses its own condition resolution: when it sees "types" in the conditions list, it resolves to that entry for type declarations regardless of the runtime format.

The most reliable pattern is:

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

The types condition comes first. TypeScript stops at the first match, so types must be before import and require. If you put import first, TypeScript might resolve to ./dist/esm/index.js and then try to discover types from there, which may or may not work depending on your tsconfig.json settings.

If you use moduleResolution: "bundler" (the recommended setting for modern TypeScript projects), TypeScript also respects "types" conditions in subpath exports:

{
  "exports": {
    ".": {
      "types": "./dist/types/index.d.ts",
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js"
    },
    "./react": {
      "types": "./dist/types/react/index.d.ts",
      "import": "./dist/esm/react/index.js",
      "require": "./dist/cjs/react/index.js"
    }
  }
}

TypeScript 5.x also introduced the @types resolution for exports-only packages. If you ship types inside the package (not via DefinitelyTyped), the types condition is the only reliable way to point TypeScript at the right .d.ts file for each subpath.

Self-referencing: import from your own package

With exports, your own package files can import each other using the package name instead of relative paths. Node.js calls this “self-referencing”:

{
  "name": "@yourorg/utils",
  "exports": {
    ".": "./dist/index.js",
    "./internal/*": "./dist/internal/*"
  }
}

Now src/index.ts can do:

import { helper } from "@yourorg/utils/internal/helper"

instead of:

import { helper } from "../internal/helper"

This is most useful in monorepos where you want the same import style for cross-package and intra-package references. The self-reference resolves through the exports map, so you still get the boundary enforcement — you can only import what you explicitly exported.

Debugging ERR_PACKAGE_PATH_NOT_EXPORTED

This error is the most common complaint about exports. The message looks like:

Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './dist/helpers/parse' is not defined by "exports" in /path/to/node_modules/@yourorg/utils/package.json

It means the consumer tried to import a path that is not in your exports map. The fix is always one of three things:

  1. Add the path to exports if it should be public.
  2. Move the consumer to a documented entry point.
  3. If the consumer is TypeScript with moduleResolution: "node" (old-style resolution), they may need to switch to "node16", "nodenext", or "bundler" because the old node resolution did not respect exports.

The third case trips up the most teams. TypeScript’s moduleResolution: "node" (the default before TS 5.0) does not look at exports at all. It uses the old main field. If you removed main and only set exports, consumers on older TypeScript get silent resolution failures. The fix is to ship both main and exports during a migration window:

{
  "main": "./dist/cjs/index.js",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js"
    }
  }
}

When both main and exports are present, modern Node.js and TypeScript honor exports first. Old consumers fall back to main. This dual-config is safe to ship and lets you migrate your consumer base gradually.

Migration checklist for existing packages

If you have a package that currently uses main and you want to add exports, here is the step-by-step migration:

Step 1: Audit what consumers actually import

grep -r "require('your-package" node_modules/your-package-consumers/ --include="*.{js,ts,jsx,tsx}"
grep -r "from 'your-package" node_modules/your-package-consumers/ --include="*.{js,ts,jsx,tsx}"

If anyone imports your-package/dist/something, that path needs to be in your exports map or you will break them.

Step 2: Add exports alongside main

Do not remove main yet. Add exports with the same root entry point and add explicit entries for every import path your audit discovered:

{
  "main": "./dist/index.js",
  "exports": {
    ".": "./dist/index.js",
    "./dist/*": "./dist/*",
    "./package.json": "./package.json"
  }
}

The ./dist/* wildcard preserves backward compatibility for deep imports while you document the new surface.

Step 3: Deprecate deep imports

Mark the old deep import paths with a warning or a README notice. After a major version bump, remove the wildcard and list only the intended public paths:

{
  "exports": {
    ".": "./dist/index.js",
    "./react": "./dist/react/index.js",
    "./testing": "./dist/testing/index.js",
    "./package.json": "./package.json"
  }
}

Step 4: Remove main (optional)

Once all consumers have migrated to exports-compatible resolution, you can remove main. Keep it if you want to support very old Node.js versions (pre-12.7.0) or TypeScript with moduleResolution: "node".

Step 5: Add conditional exports for dual format

If your build pipeline produces both CJS and ESM output, add the conditional structure:

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

Common mistakes and how to fix them

Mistake: Forgetting the ./ prefix

// Wrong
{ "exports": { ".": "dist/index.js" } }

// Right
{ "exports": { ".": "./dist/index.js" } }

Node.js throws ERR_INVALID_PACKAGE_TARGET for targets that do not start with ./.

Mistake: Putting conditions on the key instead of the value

// Wrong: condition on the key
{ "exports": { "import": "./dist/esm/index.js", "require": "./dist/cjs/index.js" } }

// Right: condition on the value
{ "exports": { ".": { "import": "./dist/esm/index.js", "require": "./dist/cjs/index.js" } } }

The keys of the top-level exports object must be paths (. or ./subpath). Conditions go inside the value for each path.

Mistake: Ordering types after import

// Wrong: TypeScript picks import before it sees types
{ "exports": { ".": { "import": "./dist/esm/index.js", "types": "./dist/types/index.d.ts" } } }

// Right: types first
{ "exports": { ".": { "types": "./dist/types/index.d.ts", "import": "./dist/esm/index.js" } } }

Mistake: Exporting a directory without a trailing slash

// Wrong: ambiguous
{ "exports": { "./internal": "./dist/internal" } }

// Right: explicit file or wildcard
{ "exports": { "./internal": "./dist/internal/index.js" } }

Mistake: Using exports without main and supporting old consumers

If your consumer base includes anything running Node.js < 12.7.0 or TypeScript < 4.7 with moduleResolution: "node", keep main as a fallback. Removing it is a breaking change for a significant portion of the ecosystem.

A realistic production exports field

Here is what a complete, production-ready exports field looks like for a library that ships CJS, ESM, types, and has React and testing subpaths:

{
  "name": "@yourorg/utils",
  "type": "module",
  "main": "./dist/cjs/index.js",
  "types": "./dist/types/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/types/index.d.ts",
      "node": {
        "import": "./dist/esm/node/index.js",
        "require": "./dist/cjs/node/index.js"
      },
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js"
    },
    "./react": {
      "types": "./dist/types/react/index.d.ts",
      "import": "./dist/esm/react/index.js",
      "require": "./dist/cjs/react/index.js"
    },
    "./testing": {
      "types": "./dist/types/testing/index.d.ts",
      "import": "./dist/esm/testing/index.js",
      "require": "./dist/cjs/testing/index.js"
    },
    "./package.json": "./package.json"
  }
}

This covers:

  • TypeScript consumers at every subpath
  • Node.js-specific builds (using fs or crypto) for server environments
  • Universal builds for bundlers and browsers
  • Both import and require entry points
  • Backward compatibility via main and types at the root

Takeaway

The exports field in package.json is the most important packaging tool Node.js has added in the last five years. It replaces the ad-hoc main field with an explicit, enforceable API boundary. It gives you conditional resolution per module system, per environment, and per consumer tool. It makes deep internal imports a thing of the past.

But it is not free. You have to understand the condition evaluation order, the ./ prefix requirement, the TypeScript compatibility matrix, and the migration path for existing consumers. Ship the wrong exports config and your users get ERR_PACKAGE_PATH_NOT_EXPORTED with no clue why. Ship the right one and your package works identically across CJS, ESM, TypeScript, and every bundler in the ecosystem.

If your package does not have an exports field yet, start with the simple version today. Add ".": "./dist/index.js" and watch your support questions about “why can I require this file” disappear. Then add subpath exports. Then add conditions. Each layer is a few lines of JSON and years of avoided confusion.

A note from Yojji

Defining clear package boundaries and managing dual-format builds is the kind of infrastructure detail that separates a hobby project from a library that hundreds of teams depend on without filing issues. It requires understanding the Node.js module resolution algorithm, TypeScript’s type declaration discovery, and a build pipeline that produces correct output for every target. Yojji’s engineering teams work across the full JavaScript ecosystem, building and maintaining packages, microservices, and client applications that ship to production reliably. With expertise in Node.js, TypeScript, React, and cloud infrastructure on AWS, Azure, and Google Cloud, they turn packaging complexity into a solved problem so your team can focus on features instead of module resolution errors.