Right, let’s talk about the exports field. This is where we graduate from “just publishing some files” to actually building a proper, thoughtful library. If you’re still using the old "main" and "types" fields, I’m not mad, just disappointed. It’s like using a flip phone in 2024—it works, but you’re missing out on a world of security, control, and sanity.

The exports field in your package.json is your library’s bouncer. It explicitly tells the outside world (and TypeScript) which entry points are public and how to find them. Everything else? It’s off-limits. This is called “package encapsulation,” and it’s the single best way to avoid the dreaded “hey, why can my users import this internal file I never meant to expose?” problem. We’ve all been there. It’s not fun.

The Basic Structure: Your Main Gate

At its simplest, exports defines your primary entry point, replacing both "main" and "types". The key is that you need to provide both the JavaScript and the TypeScript type definitions. Here’s how you do it without breaking a sweat.

{
  "name": "my-awesome-lib",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

See what we did there? Under the "." key (which represents the root of your package, i.g., import stuff from 'my-awesome-lib'), we provide three conditions:

  • types: This is the path to your type definitions. This is TypeScript’s direct line to understanding your library. Always put this first. Tooling is supposed to prioritize it, and it just makes sense.
  • import: This is for ECMAScript module consumers (using import).
  • require: This is for CommonJS consumers (using require()).

This is objectively better than the old way. Why? Because if some maniac tries to require() your ESM-only entry point, Node.js can now look at this table and say “ah, they want CommonJS, I’ll give them the ./dist/index.cjs file instead.” You’ve just saved someone from a cryptic error message. You’re a hero.

Subpath Exports: The Real Superpower

This is where exports earns its keep. Remember when you’d do import { thing } from 'your-lib/utils' and just pray it worked? With exports, you dictate exactly which internal paths are available.

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

Now, a user can only import from 'my-awesome-lib' and 'my-awesome-lib/utils'. Trying to import 'my-awesome-lib/dist/internal/secret-stuff' will fail spectacularly at resolution time. You’ve locked down your API. This is a best practice you should adopt immediately.

Note the "./package.json": "./package.json" entry. This is a weird but necessary concession. Some tools (I’m looking at you, old Webpack versions) demand access to the package.json file. Without this explicit export, they’d get a resolution error. It’s the one weird exception to the encapsulation rule.

The TypeScript Resolution Dance

Here’s the part the official docs gloss over: TypeScript only respects the exports field if you tell it to. You need to ensure your tsconfig.json is configured for modern module resolution.

In your library’s tsconfig.json, you must have:

{
  "compilerOptions": {
    "module": "NodeNext", // or "ESNext"
    "moduleResolution": "NodeNext", // or "Bundler" if you're sure
    "target": "ES2022",
    "outDir": "dist",
    // ... other options
  }
}

The magic is in "moduleResolution": "NodeNext". This tells TypeScript “hey, go read the exports field in package.json and believe what it tells you.” The older "Node" resolution strategy doesn’t fully understand exports, leading to the infuriating situation where your code runs fine in Node.js but TypeScript screams about missing types. It’s a classic “works in runtime, breaks in editor” headache that "NodeNext" solves.

The Pitfalls and How to Avoid Them

  1. The Fallacy of the Wildcard: You might see people try to export everything with a wildcard ("./*": "./dist/*"). Don’t. You’re defeating the entire purpose of encapsulation. Be explicit. Your users don’t need access to your dist/test-helpers directory.

  2. The Relative Path Trap: All paths inside the exports map must start with ./. This is not a suggestion; it’s a requirement. Writing "types": "dist/index.d.ts" will fail. It must be "types": "./dist/index.d.ts".

  3. The Conditional Order Matters: The order of keys in an export condition can matter for some tools, though Node itself defines a specific order of precedence. The safe bet is to always put "types" first. It’s the most unambiguous signal.

  4. The Dual Package Hazard: If you’re providing both ESM and CJS builds (which you should be, it’s 2024), your subpath exports must provide both an import and require condition for every entry point. Forgetting one will break half your users. Use a build tool that outputs both formats to separate files (like .mjs and .cjs) to make this trivial.

The exports field is your declaration of intent. It’s you saying, “This is my public API. It is clean, it is controlled, and it is well-typed.” It requires a bit more upfront work, but it saves you and your users from countless hours of debugging obscure module errors. It’s the mark of a professional library author. Now go implement it.