この TypeScript が Hello, world! のくせに慎重すぎる

## この記事は

npm へ公開可能なパッケージを TypeScript で作成しながら、JS/TS 開発で良く使われるツールを紹介する記事です。

`typescript-npm-starter` という名前の Hello, world! パッケージを公開するという体で話を進めます。

## Hello, world!

まずは今回作る Hello, world! パッケージの仕様です。

`main` 関数をエクスポートしていて、それを実行すると Hello, world! をプリントします。

```sh
$ npm i typescript-npm-starter
$ node -e "require('typescript-npm-starter').main()"
Hello, world!
```

今後の保守性を考えて JavaScript ではなく TypeScript を選択しました。

## TypeScript

何はともあれまずは TypeScript での初期実装をします。

`typescript` と同時に `rimraf` というクロスプラットフォーム版の `rm -rf` 的なパッケージも追加します。

ビルド前に前回のビルド成果物を削除するために使います。

```sh
$ npm init -y
$ npm i -D typescript rimraf
```

以下のように各ファイルを作成します。

```tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "lib": ["esnext"],
    "types": ["node"],
    "strict": true,
    "importHelpers": true,
    "experimentalDecorators": true,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "outDir": "dist"
  },
  "include": ["src"]
}
```

```src/lib/index.ts
export function main(): void {
  console.log("Hello, world!");
}
```

以後、追加変更する部分のみを記載します。

```package.json
{
  "files": ["dist"],
  "main": "dist/lib/index.js",
  "scripts": {
    "prebuild": "rimraf dist",
    "build": "tsc"
  }
}
```

以下のようにビルドして実行できるようになりました。

```sh
$ npm run build
$ node -e "require('.').main()"
Hello, world!
```

`dist` フォルダに JS が出力されます。

## ユニットテスト (Jest)

今後の機能追加や仕様変更の際に壊れないよう、ユニットテストを追加します。

Jest というフレームワークが人気です。

```sh
$ npm i -D jest @types/jest ts-jest
```

設定ファイルを作成します。

今回は `*.test.ts` というファイル名をテストファイルとして認識するように設定します。

```jest.config.js
/** @type {import('@jest/types/build/Config').InitialOptions} */
module.exports = {
  preset: "ts-jest",
  testMatch: ["<rootDir>/src/**/*.test.ts"],
  collectCoverage: true,
  errorOnDeprecated: true,
  testEnvironment: "node"
};
```

テストファイルを TypeScript で書くのに必要な型定義ファイルを tsconfig に追加します。

また、テストファイル自体はビルドしなくていいので exclude しておきます。

```tsconfig.json
{
  "compilerOptions": {
    "types": ["node", "jest"]
  },
  "exclude": ["**/*.test.ts"]
}
```

`main()` 関数が正しく `Hello, world!` してくれるか心配なので、以下のようなテストを書きました。

```src/lib/index.test.ts
import { main } from ".";

describe("main()", () => {
  it("print message", () => {
    const log = jest.spyOn(console, "log").mockReturnValue();
    main();
    expect(log).nthCalledWith(1, "Hello, world!");
    log.mockRestore();
  });
});
```

ユニットテストの実行コマンドを `test` スクリプトとして定義します。

```package.json
  "scripts": {
    "build": "tsc",
    "test": "jest"
  }
```

これでテストできるようになりました。

```sh
$ npm t
```

カバレッジもバッチリです。

```sh
$ npx http-server -o coverage/lcov-report
```

そろそろ `dist` などが Git に追加されないか心配になってきたので除外します。

```:.gitignore
coverage/
dist/
node_modules/
```

## 環境明示 (engines / .npmrc)

Node.js v12 で開発しているとします。

v8 や v10 で動くかどうか分からないのに、誰かが古い Node.js で `npm install typescript-npm-starter` などしてマシンが壊れないか心配です。

また npm や yarn などのパッケージマネージャはそのバージョンによってロックファイル (`package-lock.json` など) の生成ロジックが異なることがあります。

もし今後このパッケージの開発者が増えて、皆が好き勝手な Node.js 環境でプルリクして毎回 `package-lock.json` を変えてくれては困ります。

幸いなことにどちらも防げる方法があります。

```package.json
{
  "engines": {
    "node": ">= 12",
    "npm": ">= 6.12"
  }
}
```

これで対応バージョンを宣言できます。

そして以下のファイルを作成します。

```:.npmrc
engine-strict = true
save-exact = true
```

`engine-strict = true` を指定すると、`engines` で指定したバージョン以外で install しようとした時、以下のようなエラーを吐いて停止してくれるようになります。

```
npm ERR! code ENOTSUP
npm ERR! notsup Unsupported engine for typescript-npm-starter@0.0.0-development: wanted: {"node":">= 12","npm":">= 6.12"} (current: {"node":"10.16.3","npm":"6.9.0"})
npm ERR! notsup Not compatible with your version of node/npm: typescript-npm-starter@0.0.0-development
npm ERR! notsup Not compatible with your version of node/npm: typescript-npm-starter@0.0.0-development
npm ERR! notsup Required: {"node":">= 12","npm":">= 6.12"}
npm ERR! notsup Actual:   {"npm":"6.9.0","node":"10.16.3"}
```

分かりやすいですね。

## コーディング規約 (ESLint / Prettier)

環境が同じでもコードのスタイルや品質は十人十色です。

この Hello, world! を OSS として公開したら、2 スペースとシングルクォートが好きなのにタブとダブルクォートのプルリクが来てしまわないか心配です。

そこで

- スペースやタブ、クォートの種類などのスタイル
- ある程度のコーディング規約、ベストプラクティスの強制

ができるように設定します。

前者は Prettier と ESLint、後者は ESLint が主に担っています。

```sh
$ npm i -D @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint eslint-config-prettier eslint-plugin-import eslint-plugin-prettier eslint-plugin-simple-import-sort prettier
```

単純にどちらも適用すると設定によってはコンフリクトが起こり、不安定な差分が出たりエディタがフリーズしたりするなどの原因となるため、現在は ESLint のプラグインとして Prettier を導入して ESLint だけを適用する、というのが主流となっています。

```js:.eslintrc.js
/** @type {import('eslint').Linter.Config} */
module.exports = {
  root: true,
  extends: [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:import/recommended",
    "plugin:import/typescript",
    "prettier/@typescript-eslint",
    "plugin:prettier/recommended"
  ],
  plugins: ["simple-import-sort"],
  env: {
    es6: true,
    node: true,
    jest: true
  },
  rules: {
    "@typescript-eslint/explicit-function-return-type": "off",
    "@typescript-eslint/no-empty-function": "off",
    "@typescript-eslint/no-use-before-define": "off",
    "import/default": "off",
    "import/order": "off",
    "no-console": "off",
    "no-empty": ["error", { allowEmptyCatch: true }],
    "simple-import-sort/sort": "error",
    "sort-imports": "off"
  },
  overrides: [
    {
      files: "*.js",
      rules: {
        "import/order": ["error", { "newlines-between": "always" }],
        "simple-import-sort/sort": "off"
      }
    },
    {
      files: ["*.{config,d,test}.ts"],
      rules: {
        "@typescript-eslint/no-explicit-any": "off"
      }
    }
  ]
};
```

```:.eslintignore
!*.js
!*.ts
coverage/
dist/
node_modules/
```

デフォルトではドット始まりのファイル名 (`.*`) が対象外とされているため、JS/TS は対象外から除外しておきます。

```prettier.config.js
/** @type {import('prettier').Options} */
module.exports = {
  printWidth: 100,
  semi: false,
  singleQuote: true,
  trailingComma: "all",
  overrides: [
    {
      files: "*.{md,yml}",
      options: {
        printWidth: 80,
        semi: true,
        singleQuote: false,
        trailingComma: "none"
      }
    }
  ]
};
```

```:.prettierignore
coverage/
dist/
node_modules/
```

こちらは `.gitignore` と同一で OK です。

`scripts` を一気に増やすので便利コマンドを追加します。

```sh
$ npm i -D npm-run-all
```

```package.json
{
  "scripts": {
    "fix": "run-p fix:*",
    "fix:eslint": "npm run lint:eslint -- --fix",
    "fix:prettier": "npm run lint:prettier -- --write",
    "lint": "run-p lint:*",
    "lint:eslint": "eslint --ext js,ts .",
    "lint:prettier": "prettier -l \"**/*.{json,md,yml}\"",
    "lint:type": "tsc --noEmit",
    "test": "run-s lint test:*",
    "test:unit": "jest"
  }
}
```

`npm run lint` で `eslint`, `prettier`, `tsc` (タイプチェック) が一気に実行されます。

また `npm run test` で `npm run lint` のすべてと `jest` が実行されます。

```sh
$ npm t
```

`fix` は `lint` を行い、出たエラーが自動修正可能なものならその修正を適用するコマンドです。

```sh
$ npm run fix
```

## エディタ (Visual Studio Code)

今更ですがエディタの設定をします。

コードを 1 行書くたびに `fix` したり `lint` エラー箇所を目で探すのは ready perfectly と言えません。

---

まずは拡張機能です。

以下のファイルを Git に追加することで、新しい開発者がこのプロジェクトを VSCode で開いた時にインストールを促す通知が出ます。

見た目やシステム言語に関する拡張をプロジェクトへ入れるかどうかは議論の余地がある部分でしょうが、個人的に強く推奨したい便利なものは入れてしまいます。

もちろんインストールせずに無視することもできます。

Hello, world! には最低でもこれだけ必要です。

```json:.vscode/extensions.json
{
  "recommendations": [
    "christian-kohler.path-intellisense",
    "coenraads.bracket-pair-colorizer",
    "dbaeumer.vscode-eslint",
    "eamodio.gitlens",
    "esbenp.prettier-vscode",
    "ms-ceintl.vscode-language-pack-ja",
    "oderwat.indent-rainbow",
    "orta.vscode-jest",
    "pflannery.vscode-versionlens",
    "shardulm94.trailing-spaces",
    "streetsidesoftware.code-spell-checker",
    "yzhang.markdown-all-in-one"
  ]
}
```

保存したら VSCode を再起動して通知が出ることを確認し、インストールします。

---

次に設定ファイルです。

先に説明したような ESLint と Prettier のコンフリクトをうまく防ぐ設定です。

`javascript` と `typescript` については Prettier と `editor.formatOnSave` を無効にして、ESLint だけを効かせるのがポイントです。

```json:.vscode/setting.json
{
  "[javascript]": {
    "editor.formatOnSave": false
  },
  "[typescript]": {
    "editor.formatOnSave": false
  },
  "editor.codeActionsOnSave": {
    "source.fixAll": true
  },
  "editor.formatOnSave": true,
  "editor.rulers": [100],
  "eslint.validate": ["javascript", "typescript"],
  "prettier.disableLanguages": ["javascript", "typescript"],
  "typescript.tsdk": "node_modules/typescript/lib"
}
```

## コミットフック (lint-staged)

しかし、VSCode 以外のエディタで開発して `npm run fix` せずに push することは依然として可能です。

すべてのプルリクエストにそういう可能性がある状態ではレビューの負担が大きいので、Hello, world! を公開する前に対策が必要です。

そこで、エラーのあるコードをそもそもコミットできないように制限してしまいます。

```sh
$ npm i -D husky lint-staged
```

```husky.config.js
module.exports = {
  hooks: {
    "pre-commit": "lint-staged"
  }
};
```

Git の pre-commit フックで `lint-staged` コマンドを実行してくれます。

`lint-staged` は Git のステージングファイルそれぞれに対して設定に応じたコマンドを実行してくれます。

```lint-staged.config.js
module.exports = {
  "*.{js,ts}": "eslint --fix",
  "*.{json,md,yml}": "prettier --write"
};
```

いずれかのコマンドがコード 0 以外で終了したらコミットは中止されます。

最後に `git add <対象>` が実行されたあとにコミットプロセスが再開するため、コミットされるファイルは常に自動修正された状態となります。

## CircleCI (CI)

まだです。

コミットフックは `git commit -n` でバイパスできます。

あるいは手動で無力化されているかもしれません。

最後は CI で守らなければやはりレビューの負担は大きいものとなります。

今回は CircleCI を利用します。

(設定ファイル以外の導入方法は数ある他の記事に譲ります)

https://qiita.com/tatane616/items/8624e61473a9957d9a81
https://qiita.com/gold-kou/items/4c7e62434af455e977c2

これにより GitHub のステータスを確認するだけで test をパスしたかどうかが一目瞭然となります。

```yml:.circleci/config.yml
version: 2.1

orbs:
  node: circleci/node@3.0.0

workflows:
  default:
    jobs:
      - node/test:
          matrix:
            parameters:
              version:
                - "14.4"
                - "12.18"
```

今回は Node.js v12 と v14 の 2 バージョンで自動テストさせます。

current である v14 で動かなくなってもすぐに検知できるので、いざバージョンアップしようとしたときにドタバタせず済みます。

最新版だけでいい場合は以下のように設定できます。

```yml:.circleci/config.yml
version: 2.1

orbs:
  node: circleci/node@3.0.0

workflows:
  default:
    jobs:
      - node/test
```

## bin (コマンド)

ところで、npm パッケージにはこれまでに紹介した ESLint や Prettier のようにコマンドラインで実行できるものがあります。

`typescript-npm-starter` も、ユーザーはコマンドとして実行したいと思うかもしれません。

さらに、コマンドがあればインストールせず `npx` で実行できるようにもなります。

```sh
$ npx typescript-npm-starter
Hello, world!
```

まだ公開せず、一旦神界に戻って修行しましょう。

```src/bin/index.ts
#!/usr/bin/env node

import { main } from "../lib";

main();
```

shebang つきの TS ファイルです。

`tsc` でトランスパイルした JS にも残ります。

Node.js は shebang をコメントとして解釈できます。

```package.json
{
  "bin": {
    "typescript-npm-starter": "dist/bin/index.js"
  }
}
```

この記述をしておくと `typescript-npm-starter` という名前のコマンド (実体は `dist/bin/index.js` へのシンボリックリンク) がインストールされて使えるようになります。

## ES Modules

ここまで TypeScript で開発してきたように、`typescript-npm-starter` を利用する側もまた TypeScript で開発しているかもしれません。

実は Node.js モジュールシステムは CommonJS といって、TypeScript の ES Modules とは別物です。

TypeScript から CommonJS モジュールを使うためには tsconfig に余計な設定を増やしたり型定義ファイルを書いたりする必要があります。

TypeScript 製なのに TypeScript から活用しづらいという滑稽な状態なので対応します。

今回は型定義ファイルのソースマップとオリジナルのソースを配布物に同梱します。

以下のような `dist` を出力できれば完成です。

```
dist
├── bin
│   └── index.js
├── esm
│   ├── index.d.ts
│   ├── index.d.ts.map
│   └── index.js
└── lib
    └── index.js
```

```tsconfig.esm.json
{
  "extends": ".",
  "compilerOptions": {
    "module": "esnext",
    "declaration": true,
    "declarationMap": true,
    "outDir": "dist/esm"
  },
  "include": ["src/lib"]
}
```

ES Modules にトランスパイルする tsconfig です。

もとの tsconfig から継承し、`module` を `commonjs` から `esnext` に変更しています。

`declaration` で型定義ファイル、`declarationMap` でそのソースマップを生成します。

```package.json
{
  "files": ["dist", "src"],
  "main": "dist/lib/index.js",
  "module": "dist/esm/index.js",
  "types": "dist/esm/index.d.ts",
  "bin": {
    "typescript-npm-starter": "dist/bin/index.js"
  },
  "scripts": {
    "prebuild": "rimraf dist",
    "build": "run-p build:*",
    "build:common": "tsc",
    "build:esm": "tsc -p tsconfig.esm.json"
  }
}
```

`module` と `types` フィールドが重要です。

そして配布対象を指定する `files` に `src` を追加しています。

あらためてビルドしたら、最後に配布物の確認をします。

```sh
$ npm run build
$ npm pack --dry-run
```

```
npm notice
npm notice 📦  typescript-npm-starter@0.0.0-development
npm notice === Tarball Contents ===
npm notice 144B  dist/bin/index.js
npm notice 61B   dist/esm/index.js
npm notice 152B  dist/lib/index.js
npm notice 1.5kB package.json
npm notice 143B  dist/esm/index.d.ts.map
npm notice 73B   dist/esm/index.d.ts
npm notice 235B  src/lib/index.test.ts
npm notice 59B   src/bin/index.ts
npm notice 64B   src/lib/index.ts
npm notice === Tarball Details ===
npm notice name:          typescript-npm-starter
npm notice version:       0.0.0-development
npm notice filename:      typescript-npm-starter-0.0.0-development.tgz
npm notice package size:  1.3 kB
npm notice unpacked size: 2.4 kB
npm notice shasum:        0c4f60955ea3b82c7fa4d4ee36e948c55ca53b62
npm notice integrity:     sha512-21Gvgd9Mt+2IW[...]V/+3FCR1bX/xQ==
npm notice total files:   9
npm notice
typescript-npm-starter-0.0.0-development.tgz
```

## まとめ

https://github.com/matzkoh/typescript-npm-starter

Hello, world! に本気を出した結果、ちょっとした TS ライブラリや日々の自動化コマンドをサクッと作って公開するのに便利なテンプレートが完成しました。

また堅牢なエコシステム構築は、新規参画者のキャッチアップを促進する助けにもなります。

「monorepo じゃない。やり直し」「CDN 対応してないやん」「ここまで commitlint なし」

など言いたいことは山ほどあるかもしれません。

コメントや編集リクエストで皆さんの慎重さを見習わせてください。