🌵

型チェックが緩いTypeScriptプロジェクトでコミット対象ファイルだけ厳格に型チェックしたい

2021/01/04に公開
4

はじめに

TypeScriptプロジェクトにおいて、tsconfigのコンパイラオプションでの型に関するオプション(strict, noImplicitAny, strictNullChecks, strictFunctionTypesなど)が有効になっていない場合、型安全性が損なわれてしまいます。せっかくTypeScriptを利用しているので、可能な限り型安全性を高めておきたいです。

そのように型に関するルールが緩いプロジェクトがあった場合、今すぐにでもstrict: trueに変更するのがベストですが、そう簡単に変更できるとは限りません。
ある程度プロジェクトの規模が大きくなると、型安全ではないコードがたくさんあり、修正にも時間と労力がかかってしまいます。

そこで今回は、Gitでコミットするファイルに対して厳密に型チェックして徐々に型安全性を高めていくというアプローチを取るための手法を利用します。[1]

前提

以下のコードがあったとします。

TsconfigErrorExample.ts
const numberList = [5, 12, 8, 130, 44];
const found: number = numberList.find((element) => element > 10);

Array.findメソッドの返り値の型はT | undefinedであり、条件にあう要素がなかった場合にundefinedが返ります。
しかし、コンパイラオプションのstrictfalse(正確にはstrictNullChecksfalse)になっている場合、foundnumber型で定義しているにも関わらず型エラーになりません。(さらに仮に型を定義していなくても、number | undefindではなくnumber型に推論されます。)

strict: trueに設定されていれば、以下のようなエラーになり、処理漏れを防ぐことができます。

Type 'number | undefined' is not assignable to type 'number'.
Type 'undefined' is not assignable to type 'number'.

このように、本当はundefinedになる可能性があるが正しく処理されていないコードに対して、コミット時に検出することを目指します。

準備

では、具体的な方法の解説に移りましょう。

まず、必要なnpmパッケージをインストールします。

npm i -D husky lint-staged
  • husky: git commitする際にlint-stagedを呼ぶためのパッケージ(husky - npm
  • lint-staged: ステージングされたファイルに対して、Linterやフォーマッターの実行を行う。(okonet/lint-staged)
    • 今回はこれを利用して、コミット対象ファイルに対して、型チェックを行います。

検証済みのバージョンは以下です。

  • husky@4.3.6
  • lint-staged@10.5.3
  • typescript@4.1.3

次に、JSONを整形するためにjqをインストールしてください。
Download jq

1. huskyの設定

インストールが完了したら、package.jsonに以下を追記します。

package.json
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged  --shell"
    }
  }

pre-commit、つまりコミット前にlint-stagedを実行します。

2. tsconfigの設定

lint-stagedで実行するコマンドを定義する前に、型チェックを行うためのtsconfigを設定しましょう。

プロジェクトですでに使っているtsconfig.jsonの他に、lint-staged実行時に使う設定ファイルtsconfig-lint-staged.json(ファイル名は任意)を用意します。

今回は型チェックを厳密に行いたいため、以下のように設定します。

tsconfig-lint-staged.json
{
    "extends": "./tsconfig",
    "compilerOptions": {
        "strict": true
    }
}

extendsオプション(TypeScript: TSConfig Reference - Docs on every TSConfig option)で既存の設定を継承し、変更したい部分だけ指定すると設定が上書きされます。

3. lint-stagedの設定

修正前の内容(ステージングされていないファイルに対しても型チェックを行ってしまいます)

残るはgit commit前にlint-stagedで実行するコマンドの設定です。

package.jsonを同じ階層にlint-staged.config.jsというファイルを作成します。
拡張子が.tsのファイルに対してtscコマンドを実行します。

lint-staged.config.js
module.exports = {
    '**/*.ts': (fileNames) => [
        'tsc -p tsconfig-lint-staged.json --noEmit',
    ],
};

オプションについては、こちら(TypeScript: Documentation - tsc CLI Options)から確認ください。[2]

(余談)ESLintやPrettierによるフォーマットも同時に行いたい場合

配列に追加すれば順番に実行できます。

lint-staged.config.js
module.exports = {
    '**/*.ts': (fileNames) => [
        'tsc -p tsconfig-lint-staged.json --noEmit',
        `eslint ${fileNames.join(' ')} --fix`,
        `prettier --write ${fileNames.join(' ')}`,
    ],
};

バージョンが古い記事にはgit addが最後に必要と書いてある場合もありますが、lint-stagedのv10では不要になったようです。

From v10.0.0 onwards any new modifications to originally staged files will be automatically added to the commit. If your task previously contained a git add step, please remove this. The automatic behaviour ensures there are less race-conditions, since trying to run multiple git operations at the same time usually results in an error.

ここでのポイントとしては、

  • -pオプションで先ほど作成したtsconfig-lint-staged.jsonを指定すること
  • コマンドはアロー関数で指定すること

です。特に2つ目に関しては、以下のように関数を用いずに文字列として指定するとtscの実行エラーが生じます。

module.exports = {
    '**/*.ts': 'tsc -p tsconfig-lint-staged.json --noEmit',
};
✖ tsc -p tsconfig-lint-staged.json --noEmit:
error TS5042: Option 'project' cannot be mixed with source files on a command line.
husky > pre-commit hook failed (add --no-verify to bypass)

例 (Example: Run tsc on changes to TypeScript files, but do not pass any filename arguments - okonet/lint-staged) にあるようにアロー関数で指定しましょう。

残るはgit commit前にlint-stagedで実行するコマンドの設定です。

package.jsonを同じ階層にlint-staged.config.jsというファイルを作成します。
拡張子が.tsのファイルに対してtscコマンドを実行します。

lint-staged.config.js
module.exports = {
    '**/*.ts': (fileNames) => [
        // Linuxの場合はこちら
        `tsc --showConfig -p tsconfig-lint-staged.json | \
        jq -j '.compilerOptions | to_entries | map("--" + .key + " " + (.value | if type=="array" then join(",") else . end | tostring)) | @sh' | \
        xargs -t -I {} sh -c 'tsc --noEmit {} ${fileNames.join(' ')}'`,
        // Windowsの場合はこちら (Linuxでも動きます)
        `tsc --showConfig -p tsconfig-lint-staged.json | \
        jq -j ".compilerOptions | to_entries | map(\\"--\\" + .key + \\" \\" + (.value | if type==\\"array\\" then join(\\",\\") else . end | tostring)) | @sh" | \
        xargs -t -I {} sh -c 'tsc --noEmit {} ${fileNames.join(' ')}'`,
    ],
};

このままでは何も分からないと思うので、1つ1つ解説します。

tsconfigの設定を取得

tsc --showConfig -p tsconfig-lint-staged.json

--showConfigオプションを用いることでコンパイラオプションをJSONで取得することができます。
例えば、以下のような出力になります。tsconfig-lint-staged.jsonの設定を読み込んでいるので、strict: trueになっています。

{
    "compilerOptions": {
        "target": "es2020",
        "module": "commonjs",
        "lib": [
            "es2019"
        ],
	"strict": true,
        "esModuleInterop": true,
        "skipLibCheck": true,
        "forceConsistentCasingInFileNames": true
    },
    "files": [
        "./src/TsconfigErrorExample.ts",
    ]
}

tscコマンドのオプションの形式に変形

上で得られたtsconfigのcompierOptionstscコマンドで使えるように変形します。JSONを変形できるjqを用います。

jq -j '.compilerOptions | to_entries | map("--" + .key + " " + (.value | if type=="array" then join(",") else . end | tostring)) | @sh'

jqのfilterに関しては説明しませんが、上記の例では以下のような文字列に変換されます。

--target es2020 --module commonjs --lib es2019 --strict true --esModuleInterop true --skipLibCheck true --forceConsistentCasingInFileNames true

tscコマンドによる型チェックの実行

上で生成したオプションを用いて、型チェックを実行します。

xargs -t -I {} sh -c 'tsc --noEmit {} ${fileNames.join(' ')}'

${fileNames.join(' ')}にはステージングされたファイル名が入ります。
実際に実行されるコマンド例は以下です。

tsc --noEmit --target es2020 --module commonjs --lib es2019 --strict true --esModuleInterop true --skipLibCheck true --forceConsistentCasingInFileNames true /mnt/e/repository/lint-staged-test/src/TsconfigErrorExample.ts

4. 動作確認

設定は以上の3ステップで完了です。それでは動作確認をしましょう。

最初に例にあげた.tsファイルを作成します。

TsconfigErrorExample.ts
const numberList = [5, 12, 8, 130, 44];
const found: number = numberList.find((element) => element > 10);

ステージングしないファイルも作ります。

UnstagedFileError.ts
const n: number = undefined;

git addしてステージング後、コミットコマンドを打つと、無事にコンパイルエラーになりコミットを防ぐことができます。

$ git add TsconfigErrorExample.ts

$ git commit -m "lint-staged test"
husky > pre-commit (node v12.16.1)
[STARTED] Preparing...
[SUCCESS] Preparing...
[STARTED] Hiding unstaged changes to partially staged files...
[SUCCESS] Hiding unstaged changes to partially staged files...
[STARTED] Running tasks...
[STARTED] Running tasks for **/*.ts
[STARTED] tsc --showConfig -p tsconfig-lint-staged.json |         jq -j ".compilerO…
[FAILED] tsc --showConfig -p tsconfig-lint-staged.json |         jq -j ".compilerOptions | to_entries | map(\"--\" + .key + \" \" + (.value | if type==\"array\" then join(\",\") else . end | tostring)) | @sh" |         xargs -t -I {} sh -c 'tsc --noEmit {} E:/repository/lint-staged-test/src/TsconfigErrorExample.ts' [FAILED]
[FAILED] tsc --showConfig -p tsconfig-lint-staged.json |         jq -j ".compilerOptions | to_entries | map(\"--\" + .key + \" \" + (.value | if type==\"array\" then join(\",\") else . end | tostring)) | @sh" |         xargs -t -I {} sh -c 'tsc --noEmit {} E:/repository/lint-staged-test/src/TsconfigErrorExample.ts' [FAILED]
[SUCCESS] Running tasks...
[STARTED] Applying modifications...
[SKIPPED] Skipped because of errors from tasks.
[STARTED] Restoring unstaged changes to partially staged files...
[SKIPPED] Skipped because of errors from tasks.
[STARTED] Reverting to original state because of errors...
[SUCCESS] Reverting to original state because of errors...
[STARTED] Cleaning up...
[SUCCESS] Cleaning up...

✖ tsc --showConfig -p tsconfig-lint-staged.json |         jq -j ".compilerOptions | to_entries | map(\"--\" + .key + \" \" + (.value | if type==\"array\" then join(\",\") else . end | tostring)) | @sh" |         xargs -t -I {} sh -c 'tsc --noEmit {} E:/repository/lint-staged-test/src/TsconfigErrorExample.ts':
sh -c 'tsc --noEmit --target es2020 --module commonjs --lib es2019 --strict true --esModuleInterop true --skipLibCheck true --forceConsistentCasingInFileNames true E:/repository/lint-staged-test/src/TsconfigErrorExample.ts'
src/TsconfigErrorExample.ts(3,7): error TS2322: Type 'number | undefined' is not assignable to type 'number'.       
  Type 'undefined' is not assignable to type 'number'.
husky > pre-commit hook failed (add --no-verify to bypass)

ステージングしていないファイル UnstagedFileError.tsは型チェックでエラーになっていないことも確認できました。

また、VSCodeのGit機能でコミットしてもエラーになります。(ただし、チェックしている分動作が遅くなります。)

エラーメッセージ

おわりに

型チェックが緩くなってしまっているTypeScriptプロジェクトに対して、これ以上型安全性を低くしないようにするための方法を紹介しました。
今回のアプローチを用いると、少しづつ改善していくことができます。

lint-stagedを用いて、ESLintやPrettierによるチェック・フォーマットも併せて行うことができるので、ぜひ活用してみてください。

読んでいただきありがとうございました。

また、一度誤った情報を掲載してしまい、申し訳ございませんでした。
検証をしっかりと行い、正しい情報を掲載するようにします。🙇‍♂️

参考サイト

脚注
  1. LINE証券フロントエンドにおける型安全性への取り組み | LINE DEVELOPER DAY 2020を参考にさせていただきました。こちらではCI環境で型チェックを行い、noImplicitAnyのエラーを減らしていったそうです。 ↩︎

  2. 今回の場合は、tsc --strict --noEmitでも代用可能です。 ↩︎

Discussion

tt

これだと、実際に実行しているコマンドが tsc -p tsconfig-lint-staged.json --noEmit になるので staged されていないファイルも厳密な型チェックの対象になってしまっていませんか?

Yuma ItoYuma Ito

ご指摘ありがとうございます。返信が遅くなり申し訳ありません。

もう一度調べてみたところ、ご指摘の通り、stagedされていないファイルも型チェックの対象となっておりました。検証が足りず、すみません。

他の方法を探ってみたいと思います。

Yuma ItoYuma Ito

タイトルの通り、stagedされているファイルのみ型チェックできるように記事の内容を修正しました。ありがとうございました。

ryo_kawamataryo_kawamata

すいません!質問です。
こちらの記事を参考に、型チェックの環境を作っていたのですが、ファイルAに依存するファイルBがあった時に(ファイルBでファイルAをimportしている)、ファイルBのみステージングすると、依存するファイルAも型チェックされる気がするのですが、どうでしょう?
※ 変更対象のファイルBに型エラーがなくても、importしているファイルAに型エラーがあるとコミット出来ない。

もし、回避策ご存知であればお願いします🙏🙏