package.jsonのexportsフィールドについて
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.jsonのexportsフィールドは以下のような形になっている。
package.jsonでのエントリーポイントの取り扱いについて
main とexportsの両方が存在する場合、exportsが優先される。exportsが優先されるけどもメインのエントリーポイントを設定する上でmainとexportsの両方を定義しておくことが推奨されている。
mainフィールドはexportsがサポートされていない環境でのフォールバックになるという意味合いがあると思う。
moduleフィールドは?
ESModule向けにmoduleフィールドもサポートしているバンドルツールや利用しているパッケージがあると思うけど、Node.jsはtypeフィールドでのmoduleによってESModule扱いとしていてmoduleフィールド自体をサポートはしていない。
Conditional exports
Node.js v12.16.0 で追加された、条件次第で異なるパスを指定できるようにするもの。条件として指定できるのは、node-addons、node、import、require、defaultなど。これによって 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.jsをexportsフィールドで指定しておくと
import helloWorld from "module-exports-playground";
といった形で同じパッケージ内において自身を参照可能になる。
TypeScript
exportsフィールドの指定に応じて型定義ファイルを参照することが必要になると思うけど、TypeScript v4.7 でサポートされる予定になっている。
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日にリリース予定。
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フィールドに関するサポート状況について項目毎に表などで詳しくまとまっている。
まとめ
npmパッケージの提供側が明確に公開するモジュールを制限できたり、モジュール種別を利用者側では気にしないようにできたり、npmパッケージの提供側がパッケージをどのように利用してもらうかをコントロールできるところで利点がありそう。
TypeScriptでのサポートが間もなく入ることでより一層利用しやすくなるかもしれない。
参考資料
- Modules: Packages | Node.js v18.1.0 Documentation
- Node.JS (New) Package.json Exports Field | by Thomas Juster | The Startup | Medium
- jkrems/proposal-pkg-exports: Proposal for Bare Module Specifier Resolution in node.js
- typescript - Importing from subfolders for a javascript package - Stack Overflow
- Announcing TypeScript 4.7 Beta - TypeScript
- TypeScript: Documentation - Publishing
Discussion