🎃

package.jsonのexportsフィールドについて

2022/05/17に公開

npm パッケージとして複数のエントリーポイントを公開したい場合、mainフィールドは単一のエントリーポイントしか受け付けないので、exportsフィールドを利用することになると思うけども、その仕様等についての確認メモ。

In a package’s package.json file, two fields can define entry points for a package: "main" and "exports". The "main" field is supported in all versions of Node.js, but its capabilities are limited: it only defines the main entry point of the package.
https://nodejs.org/api/packages.html#package-entry-points

具体的には、以下のような形で

import { a, b } from "@my-org/my-package";

ではなく

import a from "@my-org/my-package/a";
import b from "@my-org/my-package/b";

として、同じnpmパッケージの複数のエントリーポイントからimportするモジュールを定義する手段としてexportsフィールドに着目してみる。
exportsフィールドの有無にかかわらず

import x from "@my-org/my-package/sub/dir/module/x";

とすることは可能なので、どちらかというとimportできるモジュールを明示的にコントロールする手段としての確認。

主に Modules: Packages | Node.js v18.1.0 Documentation の内容に目を通しながら確認する。

exportsフィールド

exportsフィールドは、Node.js v12.7.0 から利用できるようになったフィールドで、mainと同じようにエントリーポイントを定義する役割を持っていて、exportsによってパッケージとしてのインターフェースを定義できるもの。
公開するものを定義するので反対にそれ以外のものは全て非公開となって、パッケージを利用する側からすると非公開のものはモジュールとして読み込めないものとなる。
なので、exportsに指定漏れているとモジュールが意図せずに利用できなくなってしまう状況が起こり得る(ERR_PACKAGE_PATH_NOT_EXPORTEDエラーが発生する)。
パッケージとしてのインターフェースを明確にすることでパッケージ提供側と利用側それぞれが提供・利用するものを安全にやりとりできる仕組みになるというのがメリットになるはず。
パッケージとしてカプセル化できなくても問題ないということであれば、exportフィールドで"./*": "./*"のように指定することでパッケージに含まれる全てのファイルをカプセル化しないことも可能ではある。

指定可能な形式としては<Object> | <string> | <string[]>があり、オブジェクトの形であれば記述順がそのまま優先度となる。
指定するパスは全て./で始まる相対ファイルパスの形でなければならない。

export するものが少なければ明確にそれぞれのパスを定義することを推奨しているけどもパスが膨大な量に及ぶ場合にはパスのパターンで指定することも可能。

"exports": {
  "./features/*": "./features/*.js"
},

/*は直下のディレクトリだけでなく、その配下のディレクトリ全てを含む。

exportsフィールドを実際にすでに利用しているnpmパッケージの一例としてはPreactがあり、そのpackage.jsonexportsフィールドは以下のような形になっている。

https://github.com/preactjs/preact/blob/c18db4d89dad77c1a728e5323720397986d198b8/package.json#L12-L88

package.jsonでのエントリーポイントの取り扱いについて

mainexportsの両方が存在する場合、exportsが優先される。exportsが優先されるけどもメインのエントリーポイントを設定する上でmainexportsの両方を定義しておくことが推奨されている。
mainフィールドはexportsがサポートされていない環境でのフォールバックになるという意味合いがあると思う。

moduleフィールドは?

ESModule向けにmoduleフィールドもサポートしているバンドルツールや利用しているパッケージがあると思うけど、Node.jsはtypeフィールドでのmoduleによってESModule扱いとしていてmoduleフィールド自体をサポートはしていない。

https://stackoverflow.com/questions/42708484/what-is-the-module-package-json-field-for

Conditional exports

Node.js v12.16.0 で追加された、条件次第で異なるパスを指定できるようにするもの。条件として指定できるのは、node-addonsnodeimportrequiredefaultなど。これによって ES Modules と CommonJS のように環境ごとで異なるエントリーポイントをパス指定可能になる。
以下のようにimportの場合はこのファイル、requireの場合はこのファイル、というような形で、パッケージの提供側が参照するファイルを指定することでパッケージ利用者側は特にどのファイルを見に行く必要があるかをモジュール形式を問わず気にしないで済む利点がありそう。

"exports": {
  "import": "./hello-world.js",
  "require": "./hello-world.cjs"
},

パッケージ自身の参照

なお、exportsのフィールドに定義されているモジュールはパッケージのnameフィールドとの組み合わせでそのパッケージ内において参照することができる。

"name": "module-exports-playground",
"exports": {
  ".": "./hello-world.js"
},

↑ のようにhello-world.jsexportsフィールドで指定しておくと

import helloWorld from "module-exports-playground";

といった形で同じパッケージ内において自身を参照可能になる。

TypeScript

exportsフィールドの指定に応じて型定義ファイルを参照することが必要になると思うけど、TypeScript v4.7 でサポートされる予定になっている。
https://devblogs.microsoft.com/typescript/announcing-typescript-4-7-rc/#package-json-exports-imports-and-self-referencing

v4.7 以降サポートされるようになると、以下のような形でexportsフィールドの指定に応じてtypesフィールドで型定義ファイルを指定できるようになる。

"exports": {
    ".": {
        "import": {
            "types": "./types/hello-world.d.ts",
            "default": "./hello-world.js"
        },
...

デフォルトでは、そのモジュールに対応する型定義ファイルをimportと同じようにして探すのでtypesを指定しなくても良いけど、型定義ファイルのパスがそれでは見つけられない場合にtypesを指定する必要がある。

The new support works similarly with import conditions. By default, TypeScript overlays the same rules with import conditions – if you write an import from an ES module, it will look up the import field, and from a CommonJS module, it will look at the require field. If it finds them, it will look for a corresponding declaration file. If you need to point to a different location for your type declarations, you can add a "types" import condition.
https://devblogs.microsoft.com/typescript/announcing-typescript-4-7-rc/#package-json-exports-imports-and-self-referencing

exportsフィールドをサポートしていないTypeScript向けに、mainフィールドと同じようにtypesフィールドもフォールバックとしてあると良さそう。

"exports": {
    ".": {
        "import": {
            "types": "./types/hello-world.d.ts",
            "default": "./hello-world.js"
        },
    },
},
"types": "types/index.d.ts",
"main": "index.js"

TypeScript v4.7は今月の24日にリリース予定。
https://github.com/microsoft/TypeScript/issues/48027

typesVersions

exportsフィールドがないv4.7以前でどうにかしたい場合の代替方法としてtypesVersionsを利用することで型定義を参照できるようにする方法もあるのかもしれない。

ただ、typesVersionsはTypeScriptの異なるバージョン毎に参照する型定義ファイルを切り替えられるようにするものだと思うので、本来の用途とは異なる形での利用となるはず。

As an example, if you maintain a library which uses the unknown type from TypeScript 3.0, any of your consumers using earlier versions will be broken. There unfortunately isn’t a way to provide types for pre-3.0 versions of TypeScript while also providing types for 3.0 and later.

That is, until now. When using Node module resolution in TypeScript 3.1, when TypeScript cracks open a package.json file to figure out which files it needs to read, it first looks at a new field called typesVersions.
https://devblogs.microsoft.com/typescript/announcing-typescript-3-1/#version-redirects-for-typescript-via-typesversions

exportsのバンドルツールでのサポート状況

Webpackのドキュメントではexportsフィールドに関するサポート状況について項目毎に表などで詳しくまとまっている。
https://webpack.js.org/guides/package-exports/#support

まとめ

npmパッケージの提供側が明確に公開するモジュールを制限できたり、モジュール種別を利用者側では気にしないようにできたり、npmパッケージの提供側がパッケージをどのように利用してもらうかをコントロールできるところで利点がありそう。
TypeScriptでのサポートが間もなく入ることでより一層利用しやすくなるかもしれない。

参考資料

GitHubで編集を提案

Discussion