ESM・CJS両対応のライブラリをTypeScriptから作成
本記事では、TypeScriptで書かれたソースコードを、型定義ファイルを含む、ECMAScript module (ESM)とCommonJS module (CJS)の両方に対応したnpmパッケージ (dual package) にバンドルする、以下のツールを使用したプロジェクトの構築手順をまとめます。
TL;DR
以下のGitHubリポジトリのプロジェクトをテンプレートとして、package.json
とrollup.config.js
のts-hello-world
となっている部分を全て書き換え、また、package.json
のdescription, author, licenseなどを書き換えて使ってください。
npmプロジェクトの初期化
パッケージ管理には、npm互換で高速・効率的なpnpmを採用します。
package.json
のひな形を生成するために以下のコマンドを実行します。
pnpm init
パッケージのインストール
Rollupのインストール
モジュールのバンドルには、プラグインを使ってESM用とCJS用の型定義ファイルを出力できるRollupを採用します。 ただし、TypeScriptのトランスパイルには、高速に動作するesbuildをRollupのプラグイン経由で使用します。
Rollupとプラグインを以下のコマンドでインストールします。
pnpm add --save-dev rollup rollup-plugin-esbuild rollup-plugin-dts
ESLintのインストール
ソースコードの静的解析で問題を見つけるlintツールであるESLintと、それをTypeScript対応化するtypescript-eslintを、公式の手順に従ってインストールします。
pnpm add --save-dev eslint @eslint/js globals typescript-eslint
Prettierのインストール
ソースコードを自動フォーマットするPrettierと、Prettierのルールと競合するESLintルールを無効化するeslint-config-prettierを、公式の手順に従ってインストールします。
pnpm add --save-dev --save-exact prettier
pnpm add --save-dev eslint-config-prettier
ts-jestのインストール
JavaScript用のテスト実行フレームワークであるJestと、JestでTypeScriptを扱うためのts-jestを、公式の手順に従ってインストールします。 また、TypeScriptでJestを使う時に必要となる型定義である@types/jestと、Jest用のESLintプラグインであるeslint-plugin-jestをインストールします。
pnpm add --save-dev jest typescript ts-jest @types/jest
pnpm add --save-dev @jest/globals eslint-plugin-jest
作成パッケージの基本設定
npmの公式文書を参考に、package.json
のname, versionや、description, authorなどのメタデータを設定します。
さらに、インストールしたツールを使ってソースコードのチェックやテスト、ビルドを行うスクリプトを設定します。
ここでは、以下のスクリプトを設定しています。
-
lint
: ESLintとPrettierによるソースコードのチェック -
build
: Rollupによるバンドル -
test:spec
:src/
配下の*.spec.ts
のテスト実行 -
test
: テスト実行(ビルド後のテストを含む)
環境設定
TypeScriptの設定
次に、他のツールからも参照されるTypeScriptの設定ファイルtsconfig.json
を、公式リファレンスを参照して書いていきます。
まず、compilerOptionsです。
トランスパイルにはesbuildを使用するので、最新の言語仕様が使えるように、targetに ESNext
を指定し、moduleにも ESNext
、moduleResolutionに bundler
を指定します。libには、ESNext
に加えて、DOMのAPIを使うためのDOM
、DOMのリストをIterableとして使えるようにするためのDOM.Iterable
を指定しています。
次に、pathsで、import
に指定できるパスのエイリアスを登録しています。
他のオプションは、tsc --init
で設定されるものと、公式リファレンスでRecommendedになっているものから選んで設定しています。
最後に、ソースファイルを置くsrc
ディレクトリをincludeに設定し、excludeに除外するファイルとディレクトリを設定しています。
設定後のtsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "bundler",
"paths": {
"@/*": ["./src/*"],
"@@/*": ["./*"]
},
"allowImportingTsExtensions": true,
"noUncheckedSideEffectImports": true,
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "**/*.spec.ts", "tests/**"]
}
ESLint, Prettierの設定
続いて、公式リファレンスを参照してESLintを設定します。config
というヘルパー関数を用意してくれているので、それを使って設定ファイルを書いていきます。
まず、ignores: ["dist"]
で、バンドルでファイルが出力されるdist
ディレクトリをチェックの対象外にし、eslint.configs.recommended
を指定してESLintの推奨設定を適用します。
次に、TypeScriptファイル用にtypescript-eslintの設定をします。
files
で、拡張子が ts
, mts
, cts
のファイルにマッチするglobパターンを指定します。
extends
でtypescript-eslintを使うように指定します。ここでは、推奨ルール (recommended) よりさらに厳格なルール (strict) を用い、型情報に基づくチェック (TypeChecked) も行う、strictTypeChecked
を指定しています。
languageOptions
では、まずglobals
でブラウザ環境とNode.js環境における実行時のグローバル変数を認識するように設定します。次に、型情報に基づくチェックを行うためにparserOptions
を指定します。ここで、projectService
では、tsconfig.json
で除外した.spec.ts
ファイルについても、ルール用の型情報を生成させるためにallowDefaultProject
を設定をしています。
続いて、Jest用にeslint-plugin-jestの設定をします。
ここでは、.spec
, .test
ファイルに対して推奨ルール (recommended)を適用するように設定しています。
そして、CJS
形式を用いるTypeScriptファイル (.cts
) に対する設定をします。
typescript-eslintではrequire()
をエラーとするルールno-require-imports
が有効化されていますが、.cts
ファイルについてはimport = require()
構文を許可するようにallowAsImport
を設定します。
最後に、Prettier用のルールを指定します。
設定後のeslint.config.js
import eslint from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import eslintConfigPrettier from "eslint-config-prettier";
import jest from "eslint-plugin-jest";
export default tseslint.config(
{ ignores: ["dist"] },
eslint.configs.recommended,
{
files: ["**/*.?(m|c)ts"],
extends: [tseslint.configs.strictTypeChecked],
languageOptions: {
globals: { ...globals.browser, ...globals.node },
parserOptions: {
projectService: {
allowDefaultProject: ["src/*.spec.ts"],
},
tsconfigRootDir: import.meta.dirname,
},
},
},
{
files: ["**/*.?(spec|test).?(c)ts"],
plugins: { jest },
languageOptions: {
globals: jest.environments.globals.globals,
},
...jest.configs["flat/recommended"],
},
{
files: ["**/*.cts"],
rules: {
"@typescript-eslint/no-require-imports": [
"error",
{ allowAsImport: true },
],
},
},
eslintConfigPrettier,
);
Prettierについては、デフォルト設定をそのまま使うので、空の設定ファイルを作成します。
echo '{}' > .prettierrc
ts-jestの設定
さらにts-jestを設定します。
まず、公式ドキュメントに従って、設定ファイルのひな形を作成します。
pnpm exec ts-jest config:init
生成されたjest.config.js
を書き換えます。
今回は、.cts
ファイルを扱うように、moduleFileExtensions
のデフォルト値に"cts"
を追加し、testMatch
をデフォルト値からcts
にマッチするように修正します。
設定後のjest.config.js
/** @type {import('ts-jest').JestConfigWithTsJest} **/
export default {
moduleFileExtensions: [
"js",
"mjs",
"cjs",
"jsx",
"ts",
"cts",
"tsx",
"json",
"node",
],
testEnvironment: "node",
testMatch: ["**/?(*.)+(spec|test).?(c)[jt]s?(x)"],
transform: {
"^.+.c?tsx?$": ["ts-jest", {}],
},
};
モジュールの設定
提供する形式
いよいよ作成するライブラリをESMとCJSの両対応にする設定に入ります。
作成するライブラリについて、バンドルして公開する形式ですが、今回は次のようにします。
用途 | バンドルファイル |
---|---|
Node.js等用ESM | dist/ts-hello-world.js |
Node.js等用CJS | dist/ts-hello-world.cjs |
ブラウザ用ESM | dist/ts-hello-world.min.mjs |
ESM用型定義 | dist/ts-hello-world.d.ts |
CJS用型定義 | dist/ts-hello-world.d.cts |
Node.jsやバンドラー向けには、CJSとESMの両方の形式を作成します。
一方、現在の主要ブラウザはESMに対応しているので、ブラウザ用にはESM形式だけを作成することにします。
最後にTypeScriptの型定義ファイルです。型定義ファイルを含むnpmパッケージのチェックを行うウェブサービスであるAre the types wrong?によれば、ESM, CJSそれぞれに別の型定義ファイルを公開すべきだそうですので、そのようにします。
エントリポイントの設定
作成ライブラリのnpmパッケージのエントリポイントについてpackage.json
に設定します。
現在npmでは、package.json
でエントリポイントを指定する方法として、従来の"main"
などのフィールドに加えて、"exports"
フィールドに対応しています。
Node.jsでも13.2.0以降で両方の指定方法に対応しており、TypeScriptも4.7以降で"exports"
に対応しています。
まず最初に、.js
ファイルをESM形式として判定させるために、"type"
フィールドに"module"
を指定します。
次いで、"exports"
フィールドに対応していないツール類に向けて設定します。
Node.js等用のCJS形式を"main"
に指定し、Node.js等用のESM形式を"module"
に、unpkg用の"unpkg"
フィールドにブラウザ用ファイルを指定します。そして、TypeScriptのESM用の型定義ファイルを"types"
フィールドに指定します。
そして、"exports"
フィールドを設定しますが、その設定方法は少し複雑です。Node.js公式ドキュメントやwebpack公式ドキュメントを参照して設定します。
まず、パッケージから複数モジュールをexportできるようにsubpath exportsを用いて指定します。今回は、メインエントリーポイントだけを"."
で指定します。
次に、メインエントリーポイントの内容をconditional exportsで指定します。利用できる条件は、webpack公式ドキュメントにまとめられています。記述順で最初にマッチしたものが適用されることに注意してください。
最初に、"module"
にESM対応ツールへのエントリポイントを指定します。次に、"browser"
にブラウザ用ファイルを指定します。そして、ESM用型定義ファイルを、"import"
の中の"types"
に指定します。また、"module"
条件に対応していないツールのためにESMファイルを"default"
にも指定します。最後に、CJS用に、型定義ファイルを"types"
、エントリポイントを"default"
で指定します。
設定後のpackage.json
{
"name": "ts-hello-world",
"version": "0.1.0",
"description": "A simple npm library in TypeScript as an example using Rollup",
"repository": {
"type": "git",
"url": "https://github.com/simayosi/ts-hello-world"
},
"keywords": ["Rollup", "TypeScript", "npm"],
"author": "SHIMAYOSHI, Takao",
"license": "MIT",
"type": "module",
"main": "dist/ts-hello-world.cjs",
"module": "dist/ts-hello-world.js",
"unpkg": "./dist/ts-hello-world.min.mjs",
"types": "dist/ts-hello-world.d.ts",
"exports": {
".": {
"module": "./dist/ts-hello-world.js",
"browser": "./dist/ts-hello-world.min.mjs",
"import": {
"types": "./dist/ts-hello-world.d.ts",
"default": "./dist/ts-hello-world.js"
},
"require": {
"types": "./dist/ts-hello-world.d.cts",
"default": "./dist/ts-hello-world.cjs"
}
}
},
"files": ["dist"],
"scripts": {
"lint": "eslint src && prettier --check src",
"build": "rollup --config",
"test:spec": "jest src/",
"test": "jest"
},
"devDependencies": {
"@eslint/js": "^9.16.0",
"@jest/globals": "^29.7.0",
"@types/jest": "^29.5.14",
"eslint": "^9.16.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jest": "^28.9.0",
"globals": "^15.13.0",
"jest": "^29.7.0",
"prettier": "3.4.2",
"rollup": "^4.28.1",
"rollup-plugin-dts": "^6.1.1",
"rollup-plugin-esbuild": "^6.1.1",
"ts-jest": "^29.2.5",
"typescript": "^5.7.2",
"typescript-eslint": "^8.18.0"
}
}
Rollupの設定
最後に、Rollupを使って指定したとおりにファイルをバンドルするようにrollup.config.js
を設定します。
最初に、共通設定として、TypeScriptのエントリポイントとなるファイルをcommonConfigs
のinput
フィールドとして定義します。
そして、それぞれの出力を設定していきます。
まずは、esbuildを用いてESM形式、CJS形式の両方のJavaScriptファイルへとバンドルする設定をします。
次にrollup-plugin-dtsを用いてESM用, CJS用の両方の型定義ファイルを出力する設定をします。
最後はブラウザ向けにminifyしたESM形式のバンドルを出力する設定をします。
設定後のrollup.config.js
import esbuild from "rollup-plugin-esbuild";
import { dts } from "rollup-plugin-dts";
const commonConfigs = {
input: "src/index.ts",
};
export default [
{
...commonConfigs,
output: [
{
file: "./dist/ts-hello-world.js",
format: "es",
},
{
file: "./dist/ts-hello-world.cjs",
format: "cjs",
},
],
plugins: [esbuild()],
},
{
...commonConfigs,
output: [
{
file: "./dist/ts-hello-world.d.ts",
format: "es",
},
{
file: "./dist/ts-hello-world.d.cts",
format: "cjs",
},
],
plugins: [dts()],
},
{
...commonConfigs,
output: {
file: "./dist/ts-hello-world.min.mjs",
format: "es",
},
plugins: [
esbuild({
target: ["es2020", "edge88", "firefox78", "chrome87", "safari14"],
minify: true,
}),
],
},
];
制約
残念ながら、上記のモジュール設定は、ライブラリの実装内容によってはNode.jsから使う際にdual package hazardと呼ばれる問題が生じます。
詳細はwebpack公式ドキュメントに書かれていますが、Node.js v20.19以前(v20系の場合はデフォルト設定)では、上記設定のモジュールをrequire()
を使って使用する場合にはCJSが、import
を使って使用する場合にはESMが使用されます。
モジュールがstatelessな(状態を持たない)場合には問題は生じません。
しかし、モジュールがstatefulな場合には問題が生じるので、ESM用のラッパーを用意する必要があります。
Node.js v20.19以降では、require()
でESMがロードされるようになりました。つまり、現時点でサポートされているものではv18を除けば、require()
でESMがロードされます。
しかし、これにも、モジュールがsynchronous(トップレベルawaitが使用されていない)という条件が付きます。
まとめると、statelessなモジュール、Node.js v18を無視するならsynchronousなモジュールであれば、この記事の設定でOKです。
まとめ
今回、TypeScriptの型定義ファイルを含めた、ESM, CJS両対応のnpmパッケージを作るためのプロジェクトを構築しました。
package.json
の"exports"
フィールドを適切に設定すること、それに応じてRollupを設定することが重要ポイントです。
ただ、TypeScriptからは必ずESMしか使わないという条件であれば、Viteのライブラリモードを使うので十分ではないかと思います。
Discussion