📦

ESM・CJS両対応のライブラリをTypeScriptから作成

に公開

本記事では、TypeScriptで書かれたソースコードを、型定義ファイルを含むECMAScript module (ESM)CommonJS module (CJS)の両方に対応したnpmパッケージ (dual package) にバンドルする、以下のツールを使用したプロジェクトの構築手順をまとめます。

TL;DR

以下のGitHubリポジトリのプロジェクトをテンプレートとして、package.jsonrollup.config.jsts-hello-worldとなっている部分を全て書き換え、また、package.jsonのdescription, author, licenseなどを書き換えて使ってください。
https://github.com/simayosi/ts-hello-world

npmプロジェクトの初期化

パッケージ管理には、npm互換で高速・効率的なpnpmを採用します。
https://pnpm.io/

package.jsonのひな形を生成するために以下のコマンドを実行します。

pnpm init

パッケージのインストール

Rollupのインストール

モジュールのバンドルには、プラグインを使ってESM用とCJS用の型定義ファイルを出力できるRollupを採用します。
https://rollupjs.org/
ただし、TypeScriptのトランスパイルには、高速に動作するesbuildをRollupのプラグイン経由で使用します。
https://esbuild.github.io/

Rollupとプラグインを以下のコマンドでインストールします。

pnpm add --save-dev rollup rollup-plugin-esbuild rollup-plugin-dts

ESLintのインストール

ソースコードの静的解析で問題を見つけるlintツールであるESLintと、それをTypeScript対応化するtypescript-eslintを、公式の手順に従ってインストールします。
https://typescript-eslint.io/

pnpm add --save-dev eslint @eslint/js globals typescript-eslint

Prettierのインストール

ソースコードを自動フォーマットするPrettierと、Prettierのルールと競合するESLintルールを無効化するeslint-config-prettierを、公式の手順に従ってインストールします。
https://prettier.io/
https://github.com/prettier/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を、公式の手順に従ってインストールします。
https://jestjs.io
https://kulshekhar.github.io/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などのメタデータを設定します。
https://docs.npmjs.com/cli/v10/configuring-npm/package-json

https://github.com/simayosi/ts-hello-world/blob/613ae77f76977c01cabef419ce63b71af1eea99b/package.json#L2-L15

さらに、インストールしたツールを使ってソースコードのチェックやテスト、ビルドを行うスクリプトを設定します。
https://github.com/simayosi/ts-hello-world/blob/613ae77f76977c01cabef419ce63b71af1eea99b/package.json#L38-L43

ここでは、以下のスクリプトを設定しています。

  • lint: ESLintとPrettierによるソースコードのチェック
  • build: Rollupによるバンドル
  • test:spec: src/配下の*.spec.tsのテスト実行
  • test: テスト実行(ビルド後のテストを含む)

環境設定

TypeScriptの設定

次に、他のツールからも参照されるTypeScriptの設定ファイルtsconfig.jsonを、公式リファレンスを参照して書いていきます。
https://www.typescriptlang.org/tsconfig/

まず、compilerOptionsです。
https://github.com/simayosi/ts-hello-world/blob/613ae77f76977c01cabef419ce63b71af1eea99b/tsconfig.json#L3-L11

トランスパイルにはesbuildを使用するので、最新の言語仕様が使えるように、targetに ESNext を指定し、moduleにも ESNext、moduleResolutionに bundler を指定します。libには、ESNextに加えて、DOMのAPIを使うためのDOM、DOMのリストをIterableとして使えるようにするためのDOM.Iterableを指定しています。
次に、pathsで、importに指定できるパスのエイリアスを登録しています。
他のオプションは、tsc --initで設定されるものと、公式リファレンスでRecommendedになっているものから選んで設定しています。

最後に、ソースファイルを置くsrcディレクトリをincludeに設定し、excludeに除外するファイルとディレクトリを設定しています。
https://github.com/simayosi/ts-hello-world/blob/613ae77f76977c01cabef419ce63b71af1eea99b/tsconfig.json#L24-L25

設定後のtsconfig.json
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を設定します。
https://eslint.org/docs/latest/use/configure/configuration-files
ただし、typescript-eslintはconfigというヘルパー関数を用意してくれているので、それを使って設定ファイルを書いていきます。
https://github.com/simayosi/ts-hello-world/blob/613ae77f76977c01cabef419ce63b71af1eea99b/eslint.config.js#L7-L9

まず、ignores: ["dist"]で、バンドルでファイルが出力されるdistディレクトリをチェックの対象外にし、eslint.configs.recommendedを指定してESLintの推奨設定を適用します。

次に、TypeScriptファイル用にtypescript-eslintの設定をします。
https://github.com/simayosi/ts-hello-world/blob/613ae77f76977c01cabef419ce63b71af1eea99b/eslint.config.js#L10-L22

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の設定をします。
https://github.com/simayosi/ts-hello-world/blob/613ae77f76977c01cabef419ce63b71af1eea99b/eslint.config.js#L23-L30

ここでは、.spec, .testファイルに対して推奨ルール (recommended)を適用するように設定しています。

そして、CJS形式を用いるTypeScriptファイル (.cts) に対する設定をします。
https://github.com/simayosi/ts-hello-world/blob/613ae77f76977c01cabef419ce63b71af1eea99b/eslint.config.js#L31-L39

typescript-eslintではrequire()をエラーとするルールno-require-importsが有効化されていますが、.ctsファイルについてはimport = require()構文を許可するようにallowAsImportを設定します。

最後に、Prettier用のルールを指定します。
https://github.com/simayosi/ts-hello-world/blob/613ae77f76977c01cabef419ce63b71af1eea99b/eslint.config.js#L40

設定後のeslint.config.js
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を設定します。
https://jestjs.io/docs/configuration
https://kulshekhar.github.io/ts-jest/docs/getting-started/options/

まず、公式ドキュメントに従って、設定ファイルのひな形を作成します。

pnpm exec ts-jest config:init

生成されたjest.config.jsを書き換えます。
https://github.com/simayosi/ts-hello-world/blob/613ae77f76977c01cabef419ce63b71af1eea99b/jest.config.js

今回は、.ctsファイルを扱うように、moduleFileExtensionsのデフォルト値に"cts"を追加し、testMatchをデフォルト値からctsにマッチするように修正します。

設定後のjest.config.js
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"を指定します。
https://github.com/simayosi/ts-hello-world/blob/613ae77f76977c01cabef419ce63b71af1eea99b/package.json#L16

次いで、"exports"フィールドに対応していないツール類に向けて設定します。
https://github.com/simayosi/ts-hello-world/blob/613ae77f76977c01cabef419ce63b71af1eea99b/package.json#L17-L20
Node.js等用のCJS形式を"main"に指定し、Node.js等用のESM形式を"module"に、unpkg用の"unpkg"フィールドにブラウザ用ファイルを指定します。そして、TypeScriptのESM用の型定義ファイルを"types"フィールドに指定します。

そして、"exports"フィールドを設定しますが、その設定方法は少し複雑です。Node.js公式ドキュメントwebpack公式ドキュメントを参照して設定します。
https://github.com/simayosi/ts-hello-world/blob/613ae77f76977c01cabef419ce63b71af1eea99b/package.json#L21-L34
まず、パッケージから複数モジュールをexportできるようにsubpath exportsを用いて指定します。今回は、メインエントリーポイントだけを"."で指定します。
次に、メインエントリーポイントの内容をconditional exportsで指定します。利用できる条件は、webpack公式ドキュメントにまとめられています。記述順で最初にマッチしたものが適用されることに注意してください。
最初に、"module"にESM対応ツールへのエントリポイントを指定します。次に、"browser"にブラウザ用ファイルを指定します。そして、ESM用型定義ファイルを、"import"の中の"types"に指定します。また、"module"条件に対応していないツールのためにESMファイルを"default"にも指定します。最後に、CJS用に、型定義ファイルを"types"、エントリポイントを"default"で指定します。

設定後のpackage.json
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のエントリポイントとなるファイルをcommonConfigsinputフィールドとして定義します。
https://github.com/simayosi/ts-hello-world/blob/613ae77f76977c01cabef419ce63b71af1eea99b/rollup.config.js#L4-L6

そして、それぞれの出力を設定していきます。

まずは、esbuildを用いてESM形式、CJS形式の両方のJavaScriptファイルへとバンドルする設定をします。
https://github.com/simayosi/ts-hello-world/blob/613ae77f76977c01cabef419ce63b71af1eea99b/rollup.config.js#L9-L22

次にrollup-plugin-dtsを用いてESM用, CJS用の両方の型定義ファイルを出力する設定をします。
https://github.com/simayosi/ts-hello-world/blob/613ae77f76977c01cabef419ce63b71af1eea99b/rollup.config.js#L23-L36

最後はブラウザ向けにminifyしたESM形式のバンドルを出力する設定をします。
https://github.com/simayosi/ts-hello-world/blob/613ae77f76977c01cabef419ce63b71af1eea99b/rollup.config.js#L37-L49

設定後のrollup.config.js
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