📦

Node.js パッケージ作り方 Pure ESM package と TypeScript 対応 令和最新版

2022/10/25に公開

TL;DR

既に TypeScript で Node.js パッケージを作ったことがあるなら Sindre Sorhus さんのガイドを読めば解決します。

https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c

この記事を書こうと思ったのは、Pure ESM package を理解するために twitter-intent-url という小さいライブラリを作り、その過程で「これは慣れていてもだいぶ混乱するな」と思ったため、自己整理も兼ねています。

https://www.npmjs.com/package/twitter-intent-url

Node.js のモジュールの歴史や仕様の話は丁寧に解説すると膨大になるため、ここでは概略のみ説明し、 「TypeScript を使い、Pure ESM package 形式で Node.js パッケージを作成して公開する」 というゴールにフォーカスします。

環境

2022 年 10 月現在の状況です。TypeScript の ESM Native サポートに関しては安定版とはいえ日が浅いため、変更される可能性があります。

$ node -v
v16.16.0
$ npm -v
8.10.0
$ tsc -v
Version 4.8.4

Pure ESM package とは

Node.js ではパッケージの読み込み方法として CommonJS (CJS) の require/exports と EcmaScript Module (ESM) の import/export が存在します。

Pure ESM package は名前の通り、パッケージが ESM 形式としてのみ提供されます。

この形式が広まってくるだろうと思われる理由として下記が挙げられます。

  1. ESM が CJS より様々な点でメリットがあること
  2. Node.js 12 から Native ESM サポートを行い、ESM から CJS への変換なしに ESM 形式で書かれた JavaScript を実行できるようになったこと
  3. ライブラリ作者の Sindre Sorhus さんが Pure ESM 化の具体的なガイドを発表し、それに一部のパッケージ作者が追従したこと
  4. TypeScript 4.7 から Native ESM サポートが安定版機能としてリリースされたこと

メインとしては 1 と 2 が大きいですが、TypeScript を標準的に使う人にとっては 4 が大きいでしょう。

パッケージが Pure ESM package か否か判別方法として package.json を見ることで判別できます。下記のような記述なら、Pure ESM と判定できます。

{
  // .js ファイルを ESM として解釈する
  "type": "module",
  // 読み込まれるファイルのパス
  // 記述がある場合 "main" よりも優先して読み込まれる
  // Pure ESM の場合、 Conditional Exports もされていない模様
  "exports": "./dist/index.js"
}

この辺りの仕様は Node.js のドキュメントに記載があります。

https://nodejs.org/api/packages.html#nodejs-packagejson-field-definitions

利用例

ESM の JavaScript ファイルを Node.js で実行するには下記の点に注意する必要があります。

  • package.json"type": "module" 追記が必要なこと
    • これによって拡張子 .js を ESM として解釈します
    • 省略する場合、拡張子は .mjs にする必要がある
  • 別ファイルから export したモジュールを import するときは filename.js と拡張子を省略しないこと
    • TypeScript で書かれた場合でも別ファイルは .js と指定することに注意

下記は Pure ESM package として提供されている micromark を実行するサンプルです。

mkdir esm-sandbox
cd esm-sandbox
npm init -y
touch index.js
npm install micromark
touch index.js

package.json に追記します。

{
  "type": "module"
}
import { micromark } from "micromark";

const result = micromark(`
# Title
## subtitle
contents
`);

console.log(result);

いつも通り実行するだけです。

node index.js
# `package.json` への追記を忘れた場合
(node:74672) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
(Use `node --trace-warnings ...` to show where the warning was created)

別ファイルから export したモジュールを import するときは filename.js と拡張子を省略しないで指定する必要があります。

// module.js
export const foo = () => "bar";

// index.js
import { foo } from "./module.js";
console.log(foo());

// 拡張子が省略されている場合は ERR_MODULE_NOT_FOUND が発生
// Did you mean to import ../module.js? というエラーが出る

パッケージを公開する際の package.jsontsconfig.json の設定

ここからが本題です。下記は自分が公開しているライブラリから一部を抜き出しています。

パッケージ公開に関してポイントとなる箇所はコメントを入れています。

{
  "name": "twitter-intent-url",
  "type": "module", // このパッケージが ESM として読み込まれる
  "exports": "./dist/index.js", // パッケージ読み込みエンドポイント
  "types": "./dist/index.d.ts", // 型情報ファイル
  "files": ["dist"], // パッケージ公開ディレクトリ TypeScript Compiler の出力先を指定
  "engines": {
    "node": ">=14.16" // Node.js 12 はサポートされなくなったので 14 以上を使うように指定
  },
  "scripts": {
    "build": "tsc",
    "prepack": "yarn build"
  }
}

"target": "ESNext" に関しては個人的な好みです。年度毎に更新するのが面倒なのでこの設定です。

{
  "compilerOptions": {
    "target": "ESNext", // or ES2020 出力する JavaScript のバージョン
    "module": "Node16", // ESM サポートに必要
    "moduleResolution": "Node16", // ESM サポートに必要
    "declaration": true, // 型設定ファイルの出力
    "outDir": "./dist", // TypeScript Compiler の出力先ディレクトリ
    "removeComments": true,
    "newLine": "lf",
    "isolatedModules": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "preserveSymlinks": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "skipLibCheck": true
  },
  "include": ["src"], // ビルド対象設定
  "exclude": ["src/**/*.test.ts"] // test コードを配布したくない場合は指定
}

tsconfig.json を自前で設定するのが面倒な人は "extends": "@sindresorhus/tsconfig" で設定を継承し、必要なところだけ書き加えることもできます。

https://github.com/sindresorhus/tsconfig

公開する

通常のパッケージと同様です。

// npmjs にログイン
npm login
// 公開されるファイルを確認する
npm publish --dry-run
// 公開
npm publish
// パッケージに名前空間を持つ場合の公開
npm publish --access public

注意:

ここでは説明の簡略化のために npm publish で公開していますが、チーム開発では CI/CD 経由から npm の認証トークンを使って公開するのが一般的と思われます。

まとめ

Node.js のパッケージを作る際に Pure ESM package 形式で配布するならば、 CJS と ESM 両方にビルド設定を分ける必要がない分、楽に作成できます。

ただ、利用する側の設定変更があるため、既存のプロジェクトでの移行や、フレームワーク内で利用できるかは場合によりけりです。場合によっては困難になる可能性が指摘されています。

例として Next.js では ESM で作成されたパッケージには下記の対応が必要です。

https://nextjs.org/docs/messages/import-esm-externals

Pure ESM package 形式のパッケージ配布は歴史が浅いですが、これまで複雑を極めた Node.js パッケージ開発に一筋の光を差し込むと思います。

Discussion