Native ESM + TypeScript 拡張子問題: 歯にものが挟まったようなスッキリしない書き流し
ES Modules(ESM)と CommonJS(CJS)のパッケージの相互運用については課題があり、ESM のモジュールシステムと Node のモジュールシステムに互換性がないため歴史的経緯から非常にややこしいことになっている。
Node 環境ではデフォルトが CJS のプロジェクトになり、"type": "module"
を指定することで Native ESM1なプロジェクトとなる。他には webpack を代表するバンドラーが CJS な環境で ESM の Syntax でモジュール解決を行う Fake ESM という環境があり、Next.js などで採用されいてる2。
問題点としては例えば、ESM のモジュールは基本的に ESM のコードしかインポートできず、CJS のプロジェクトでは Dynamic Import で無理矢理インポートするという不格好なやり口を強いられる。
CommonJS から ES Modules への移行する方法。トップダウンかボトムアップか | Web Scratch
また、実行するのも一筋縄ではいかず、node --loader ts-node/esm
のようにオプションを付与して実行するのだが、tsconfig.json
やpackage.json
の設定ミス、実装のミスによってエラーにハマりやすい。難解なので初見者殺しを超えて中級者を含む広い範囲に苦しみを与えてしまう。
TypeScript の ESM でハマる - くらげになりたい。
何が問題だったかというと、単一のモジュールを別々のモジュールシステムで解決しようとしているところである。そこでDual Packageといわれる、ESM と CJS の両形式でそれぞれバンドルして CJS と ESM のプロジェクトどちらからも使えるようにする解決策がある。ESM か CJS かによってインポートするファイルを変えることで相互運用を実現する。これはConditional Exportsという。例えば、CJS のプロジェクトではindex.js
を、ESM のプロジェクトではindex.mjs
をインポートするようpackage.json
で指定するといったものだ。
tsupは自作パッケージを CJS 形式と ESM 形式の両方で公開したいときに、このような Dual Package 対応の機能を提供してくれるバンドルツールだ。esbuildを採用しているため高速にビルドしてくれる。
一応 TypeScript 以外でもバンドルできることは留意したい。現代の開発では基本的に TypeScript で書くので公式のサンプルもそうなっているが、不可抗力的な事由で TypesScript で書ききれないときもある。
Anything that's supported by Node.js natively, namely .js, .json, .mjs. And TypeScript .ts, .tsx. CSS support is experimental.
pnpm add -D tsup
tsup src/index.ts src/cli.ts
/dist
にindex.js
とcli.js
が吐き出される。
tsup.config.ts
tsup.config.js
tsup.config.cjs
tsup.config.json
tsup
property in yourpackage.json
tsup.config.ts
import { defineConfig } from "tsup";
export default defineConfig({
entry: {
index: "src/index.js",
foo: "src/presets/foo.js",
bar: "src/presets/bar.js",
},
format: ["cjs", "esm"], // 出力する形式を指定
splitting: false, // バンドルしないで分割するか
sourcemap: false, // soucemapを出力するか
clean: true, // build前にディレクトリ内を削除するか
minify: process.env.NODE_ENV === "production",
treeshake: true,
});
export default defineConfig({
// Outputs `dist/index.js`
entry: ["src/index.ts"],
// Outputs `dist/a.js` and `dist/b.js`.
entry: ["src/a.ts", "src/b.ts"],
// Outputs `dist/foo.js` and `dist/bar.js`
entry: {
foo: "src/a.ts",
bar: "src/b.ts",
},
});
index.js
をひとつだけ指定する。単一のエントリーポイントにモジュールを集約してビルドする形式3。index.d.ts
に全ての型定義があるため大きめのライブラリだと見通しが悪くなる。
export default defineConfig({
entry: ["src/index.ts"],
});
// index.ts
export * from "./foo/a";
export * from "./utils";
.
├── dist
│ ├── index.d.ts
│ └── index.js
└── src
├── foo
│ └── a.ts
├── index.ts
└── utils
└── index.ts
デフォルトのエントリーポイント以外のファイルやディレクトリを柔軟に公開できる利点がある。ディレクトリ構造を維持してファイルがまとまっているため見通がよくなる。
export default defineConfig({
entry: ["src/**/*.ts"],
});
.
├── dist
│ ├── foo
│ │ ├── a.d.ts
│ │ └── a.js
│ └── utils
│ ├── index.d.ts
│ └── index.js
└── src
├── foo
│ └── a.ts
└── utils
└── index.ts
モジュールを別名でビルドしたいとき
export default defineConfig({
entry: {
".": "index.ts",
foo: "src/foo/a.ts",
utils: "src/utils/*.ts",
},
});
.
├── dist
│ ├── foo.d.ts
│ ├── foo.js
│ ├── index.d.ts
│ ├── index.js
│ └── utils
│ ├── index.d.ts
│ └── index.js
└── src
├── foo
│ └── a.ts
├── index.ts
└── utils
└── index.ts
import index from "@x7ddf74479jn5/example-package";
import foo from "@x7ddf74479jn5/example-package/foo";
import utils from "@x7ddf74479jn5/example-package/utils";
tsup src/index.ts --format esm,cjs
option | value | description |
---|---|---|
format | esm, cjs, iife | バンドル形式 |
dts | - | 型定義を出力する |
sourcemap | - | sourcemap を出力する |
watch | - | watch モード |
minify | - | minify する |
treeshake | - | treeshake するか |
tsup(esbuild)は tsc とは別のビルド方式であり、そのため tsup はtsconfig.json
を見ない。つまり、tsconfig.json
で例えばsourcemap: true
にしても反映されない。また、tsup は declaration map のサポートを意図的にしていない。必要な場合はビルドチェーン上で別途 tsc を使い出力する。
Generate TypeScript declaration maps (.d.ts.map)
TypeScript declaration maps are mainly used to quickly jump to type definitions in the context of a monorepo (see source issue and official documentation).
They should not be included in a published NPM package and should not be confused with sourcemaps.
Tsup is not able to generate those files. Instead, you should use the TypeScript compiler directly, by running the following command after the build is done:tsc --emitDeclarationOnly --declaration
.
バンドルサイズとトレードオフだが、パッケージに ts ファイルと declaration map を含めたほうがいいようだ。IDE によるコードジャンプが可能で開発者体験としてはよい。
調査:良い DX をライブラリユーザーに提供するために、TypeScript ライブラリの tsconfig 設定はどうあるべきか?
デュアルパッケージ開発者のための tsconfig (Dual Package) | TypeScript 入門『サバイバル TypeScript』
{
"name": "@x7ddf74479jn5/example-package", // [1]
"version": "1.0.0",
"description": "x7ddf74479jn5's example-package",
"main": "./dist/index.cjs", // [2]
"module": "./dist/index.js", // [3]
"types": "./dist/index.js", // [4]
"exports": {
".": {
"require": "./dist/index.cjs", // [5]
"import": "./dist/index.js", // [6]
"default": "./dist/index.js" //[7]
},
// [8]
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.cjs"
}
},
"/foo": {
"import": ".dist/foo.js"
}
},
// [9]
"files": ["dist", "src", "!src/**/*.test.ts"],
// [10]
"sideEffects": ["**/*.css"],
"type": "module", // [11]
"devDependencies": {
"tsup": "6.7.0",
"typescript": "5.0.3"
},
"scripts": {
"tsup": "tsup"
},
"license": "MIT",
"private": false, // [12]
// [13]
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
// [14]
"keywords": ["example"],
"author": "x7ddf74479jn5 <[email protected]>",
"homepage": "https://github.com/x7ddf74479jn5/example-package/tree/main/#readme",
"repository": {
"type": "git",
"url": "https://github.com/x7ddf74479jn5/example-package.git"
// "directory": "packages/foo"
},
"bugs": {
"url": "https://github.com/x7ddf74479jn5/configs/issues"
}
}
- {npm の organization name | @username}/{package-name}の形式で指定するのが推奨されいている4。npm Package registry は同一名のパッケージを公開できないため前半部のユーザー名や組織名が名前空間として機能する。
- CJS でのエントリーポイント(フォールバック)
- ESM でのエントリーポイント(フォールバック)Node 公式にサポートされているわけではないが経緯的な理由でバンドラーがサポートを続けている5。
- 型定義ファイルのエントリーポイント(フォールバック)
- CJS でのエントリーポイント
- ESM でのエントリーポイント
- ESM でのエントリーポイントのフォールバック(なくてもいい)
- 実体ファイルと型定義ファイルを同時に指定する記法。
types
を指定しなければフォールバックの方のtypes
を見に行くが、明示的な指定が推奨されいている。 - 公開するファイルやフォルダを指定する。このプロパティに指定したものがインストールされる。逆に言えば、指定しなかったものはインストールに含まれないのでインストールサイズの削減につながる。秘匿情報に注意して最小限の範囲で記述すべき。
- tree-shaking 最適化のため副作用があるファイルをバンドラーに知らせる。ここで指定していないファイルの副作用は無視される。副作用のあるファイルがない場合は
"sideEffects": false
と記述する。 type
がmodule
のときは ESM 形式のプロジェクト、CJS
(デフォルト)のときは CJS 形式のプロジェクト。tsup はこの値を見て出力ファイル(バンドル形式)を決める。type
がmodule
のときはindex.js
とindex.cjs
を出力する。type
がCJS
のときはindex.js
とindex.mjs
を出力する。true
だとパッケージを公開できない。公開する必要のない開発時にだけ利用する内部パッケージや検証用のアプリ、モノレポのルートのpackage.json
ではtrue
を指定し、公開するパッケージのみfalse
を指定する。- npm のレジストリの他に GitHub や GitLab のレジストリがある。npm はエコシステムに全体公開されるため、プライベートに利用したいパッケージは後者の GitHub などのレジストリに上げればいい。ただし、その場合 GitHub の PAT が必要になるので
package.json
内に記述するのは避け、.npmrc
の方に書き、トークンは環境変数から渡すなどする6。
main
とexports
の両方が存在する場合、exports
が優先されるが、main
とexports
の両方を定義しておくことが推奨されている。main
フィールドはexports
がサポートされていない環境でのフォールバックになる。
Conditional exports を実現するためにはexports
のフィールドにimport
とrequire
が設定されいてる必要がある。ここに ESM と CJS のように環境ごとで異なるエントリーポイントをパス指定する。
/foo
のような形でパスフィールドを指定すると、例えばimport foo from "@x7ddf74479jn5/example-package/foo"
のように下の階層のモジュールのみをパス指定でインポートできる。
author
: レポジトリの作成者のユーザー名homepage
: 公式サイトがあるならrepository
: レポジトリの URL を指定(モノレポならパッケージのディレクトリを指定->packages/foo
)bugs
: レポジトリの Issue ページの URLkeywords
: npm の検索用タグ
npm pack --dry-run
で実際に公開されるファイル一覧を確認できる。注意点としてnpm pack
とpnpm pack
ではなぜか挙動に違いがあり、含まれるファイルが違ったりする。
独自の npm registry を使う - Qiita: 検証用にローカルのパッケージをインストールする方法やローカルサーバーをレジストリとして登録する方法。
- tsup
- egoist/tsup: The simplest and fastest way to bundle your TypeScript libraries.
- デュアルパッケージ開発者のための tsconfig (Dual Package) | TypeScript 入門『サバイバル TypeScript』
- Native ESM + TypeScript 拡張子問題: 歯にものが挟まったようなスッキリしない書き流し
- CommonJS から ES Modules への移行する方法。トップダウンかボトムアップか | Web Scratch
- TypeScript の ESM でハマる - くらげになりたい。
Footnotes
-
Fake ESM に対して Node のネイティブな ESM 環境。 ↩
-
CJS 環境で
import
が使えるのはこのため。 ↩ -
barrel export とも呼ばれる。 ↩
-
javascript - What is the "module" package.json field for? - Stack Overflow ↩