🗽

Typescript+ESMでnpmパッケージ開発した備忘録

2021/03/04に公開

Typescript+ESMでnpmパッケージを作る方法が案外まとまってなかったので残しておく。マサカリ歓迎。ほぼ確実に間違い/非効率な点がある。jestでテストもやってる。基本yarnを使う。

モジュール化するときにCommonJS(requireのやつ)とESM(import/exportのやつ)とかがある。調べたところESMが業界標準で、nodejs>13.2.0とか モダンな ブラウザではすでに対応してるらしいので、ESMで行く。Tree Shakeがしやすかったりするらしい。

プロジェクトの初期化

YOUR_PACKAGE_NAME="<名前>"
mkdir $YOUR_PACKAGE_NAME && cd $YOUR_PACKAGE_NAME
yarn init -y

ESMはpackage.jsonに "type": "module"を指定しないといけないので追加しておく(nameversionと同じ階層)。参考

Typescriptの導入

yarn add -D typescript
yarn tsc --init --target es6 --module esnext --declaration true --outDir ./build --rootDir ./src

オプションを少し解説する。--module esnextでESMにコンパイルするよう指定してる。--declaration true.d.tsを生成。--target es6でes6にコンパイルする。nodejsでは6あたりから?、es6の機能が使えるようになったぽいので心配ない。ブラウザでもIE以外は対応してるし、そもそもバンドルするので問題ない。...とおもう。

パッケージの中身を作成

src/index.ts
export { IamExported } from "./module.js";
export const IamIndex = () => {
  console.log("I am exported in index.ts");
};
src/module.ts
export const IamExported = (name: string) => {
  return `Hello, ${name}!! I am exported in module.ts!`;
};

index.tsに注意してほしい。from "./module.js"のように.jsを付けている。これは間違いではない。

.jsの理由

import {hoge} from "./module"をTypescriptがコンパイルすると、手を加えずにそのまま出力される。しかしこれは正しいESMの記法ではない。本来のESMは拡張子が必要で、理想的にはTSにimport {hoge} from "./module.js"とコンパイルしてほしい。しかしこれを実現するCompilerOptionはないので次善の策として.jsを付けている。.jsを付けても同じように動くようだ。以下参照

https://github.com/microsoft/TypeScript/issues/33588

ここまできたらyarn tscすると、build/にコンパイルされた.jsと.d.tsが出てくる。以下のような構成になるはず。

src/
  - index.ts
  - module.ts
  - module.test.ts

build/
  - index.js
  - index.d.ts
  - module.ts
  - module.d.ts

publishの準備

以上でモジュール開発は終わりだが、npmにpublishするために追加で設定することがある。

まずpackage.json。最低でもこれは欲しい。

package.json
{
  "main": "./build/index.js", // JSのエントリーポイント
  "types": "./build/index.d.ts", // TSのエントリーポイント
  "publishConfig": {
    "access": "public" // デフォルトはrestricted。無料アカウントならpublicが必須。
  },
  "type": "module" // ESMを使う(再掲)
}

ほかにはlicense、repositoryとかkeywordsも指定したほうが良い。以下参照。

https://docs.npmjs.com/cli/v7/configuring-npm/package-json
https://www.typescriptlang.org/docs/handbook/declaration-files/publishing.html

ここでnpm publish --dry-runを実行すると、パッケージの概要が見れる。Tarball Contentsにはパッケージに同梱されるファイルが一覧で出てくる(多分)。

npm notice === Tarball Contents === 
npm notice 133B  babel.config.cjs  
npm notice 122B  build/index.js    
npm notice 101B  build/module.js   
npm notice 503B  package.json      
npm notice 900B  tsconfig.json     
npm notice 6.5kB jest.config.mjs   
npm notice 83B   build/index.d.ts  
npm notice 119B  src/index.ts      
npm notice 60B   build/module.d.ts 
npm notice 149B  src/module.test.ts
npm notice 107B  src/module.ts      

babel.config.cjsやjest.config.mjs, src/以下のファイルは同梱しても意味ないので省きたい。そういう時は.npmignoreを使う[1]

.npmignore
src
*config.*

これを作ってもう一度npm publish --dry-runをする。

npm notice === Tarball Contents === 
npm notice 122B build/index.js   
npm notice 101B build/module.js  
npm notice 503B package.json     
npm notice 83B  build/index.d.ts 
npm notice 60B  build/module.d.ts

必要なファイルだけ同梱していることがわかる。

動作確認

yarn linkを使うとローカルでテストできる。パッケージとは別のディレクトリで動作確認をする。

yarn link
cd ..
mkdir package-test && cd package-test
yarn init -y # "type": "module"を追加しておく
yarn link $YOUR_PACKAGE_NAME

package-test/node_modulesをみると、作ったパッケージへシンボリックリンクが貼られているのがわかる。だから次のように簡単にモジュールの動作確認ができる。package.jsonに"type":"module"を忘れないように。

import { IamExported, IamIndex } from "$YOUR_PACKAGE_NAME"
console.log(IamExported("arark")); // Hello, arark!! I am exported in module.ts!
IamIndex(); // I am exported in index.ts

npmへpublish

npmのアカウントを作る。メール認証しないとpublishが403ではじかれるので注意。

yarn login
yarn publish

ここは良い感じにやって(飽きた)。

package.jsonにこう書いておくとpublishとかの前にビルドしてくれる。

"scripts": {
  "prepare": "tsc"
 },

(付録)jestの導入

テストスクリプトを書く。

src/module.test.ts
import { IamExported } from "./module";
test("IamExported returns greeting", () => {
  expect(IamExported("arark")).toContain("Hello, arark!!");
});

jestの設定をする。

yarn add jest @types/jest -D
yarn run jest --init
✔ Would you like to use Jest when running "test" script in "package.json"? … yes
✔ Would you like to use Typescript for the configuration file? … no
✔ Choose the test environment that will be used for testing › node

これでyarn testやるとimportがわかんねえよ!と怒られる(jestがESMにネイティブ対応してないからbabelを咬ませなきゃいけないっぽい?)。
よくわかんないけどドキュメントに沿うと解決できる。具体的には:

yarn add -D babel-jest @babel/core @babel/preset-env  @babel/preset-typescript
echo 'module.exports = { presets: [["@babel/preset-env", { targets: { node: "current" } }], "@babel/preset-typescript", ],};' > babel.config.cjs

でbabelを設定する。わざわざ.cjsにしたのは、configだけCommonJSを使いたいから。.jsではpackage.jsonの"type": "module"からESMと解釈されるのでだめ。

これでjestが動くはず。

$ yarn test
yarn run v1.22.5
$ jest
 PASS  src/module.test.ts
  ✓ IamExported returns greeting (2 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.8 s
Ran all test suites.
Done in 1.35s.
脚注
  1. もしくは、package.jsonのfilesで指定することもできる。https://docs.npmjs.com/cli/v7/configuring-npm/package-json#files ↩︎

Discussion