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 tsuptsup src/index.ts src/cli.ts/distにindex.jsとcli.jsが吐き出される。
tsup.config.tstsup.config.jstsup.config.cjstsup.config.jsontsupproperty 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』
- {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 ↩
{ "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 <x7ddf74479jn5@gmail.com>", "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" } }