TypeScript and NPM package.json exports the 2024 way

The package.json exports field is the new, official standard for declaring package entry points. Using it with TypeScript is not so obvious and easy to get wrong in a way where you only find out via an issue report from a user of your library. Let’s find out how to make package.json exports work with TypeScript.

There are mainly 2 type of Node.js packages – CommonJS (CJS) and ECMAScript Modules (ESM). The former is slowly getting deprecated while the latter became the new standard.

Before the exports field you would just need to use 3 simple fields to define the entrypoints and types:

  • main – path to the CommonJS bundle
  • module – path to the ESM bundle
  • types – path to the types package

Alternatively if you opted out of supporting CommonJS and did not need multiple entrypoints, you would set the type field of package.json to be "module" and set main to point to the ESM bundle, then the rest of this article would not apply.

A vessel shipping packages for export

The module field never made it as a standard but exports and that’s what we are expected to use. The advantage of exports is that it allows to define multiple entrypoints of the package in a granular way.

However it’s not as simple as just defining the exports field like it were a non-TypeScript package and adding types field. If you do that, TypeScript will refuse to use the types in a project that uses the dependency and output an error like this:

error TS7016: Could not find a declaration file for module X implicitly has an ‘any’ type. There are types at X, but this result could not be resolved when respecting package.json “exports”. The X library may need to update its package.json or typings.

The types fields will not be respected by most bundlers and TypeScript in client packages if the exports field is used, instead a types subfield of the exports declaration must be used and it must be used in a very specific way.

An example properly defined exports field for a package that supports both CJS and ESM looks like this:

{
...
  "main": "./dist/index.common.js",
  "module": "./dist/index.mjs",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/types/index.d.mts",
        "default": "./dist/index.mjs"
      },
      "require": {
        "types": "./dist/types/index.d.ts",
        "default": "./dist/index.common.js"
      }
    }
  },
  "types": "./dist/types/index.d.ts",
...
}Code language: JavaScript (javascript)

Things to note:

  • The types import must precede the bundle (source)
  • Modules must use .mjs extension. CJS can optionally use .cjs extension, but .js will also work
  • This includes the types import, for ESM it should be .d.mts – otherwise someone using your package can run into an issue. Similarly, if you used .cjs for CJS, the types may need to be .d.cjs
  • types and module in the top scope are optional for older bundlers and older versions of TypeScript

In order to make this work you will need to copy the entrypoint for the types declaration with a .d.mts extension. If you use Rollup, you can use rollup-plugin-copy – leverage the fact that you have 2 bundles, the first bundle generates types definition, the second bundle uses the plugin to copy the definitions file generated in the first bundle (otherwise it would attempt to make a copy too soon if it was used in the bundle that generates the types). Example

With all of the above considered your project should properly support TypeScript types with package.json exports field. You can use this utility to check your exports against multiple environments.