型チェックが緩いTypeScriptプロジェクトでコミット対象ファイルだけ厳格に型チェックしたい
はじめに
TypeScriptプロジェクトにおいて、tsconfig
のコンパイラオプションでの型に関するオプション(strict
, noImplicitAny
, strictNullChecks
, strictFunctionTypes
など)が有効になっていない場合、型安全性が損なわれてしまいます。せっかくTypeScriptを利用しているので、可能な限り型安全性を高めておきたいです。
そのように型に関するルールが緩いプロジェクトがあった場合、今すぐにでもstrict: true
に変更するのがベストですが、そう簡単に変更できるとは限りません。
ある程度プロジェクトの規模が大きくなると、型安全ではないコードがたくさんあり、修正にも時間と労力がかかってしまいます。
そこで今回は、Gitでコミットするファイルに対して厳密に型チェックして徐々に型安全性を高めていくというアプローチを取るための手法を利用します。[1]
前提
以下のコードがあったとします。
const numberList = [5, 12, 8, 130, 44];
const found: number = numberList.find((element) => element > 10);
Array.find
メソッドの返り値の型はT | undefined
であり、条件にあう要素がなかった場合にundefined
が返ります。
しかし、コンパイラオプションのstrict
がfalse
(正確にはstrictNullChecks
がfalse
)になっている場合、found
はnumber
型で定義しているにも関わらず型エラーになりません。(さらに仮に型を定義していなくても、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
に以下を追記します。
"husky": {
"hooks": {
"pre-commit": "lint-staged --shell"
}
}
pre-commit
、つまりコミット前にlint-staged
を実行します。
2. tsconfigの設定
lint-staged
で実行するコマンドを定義する前に、型チェックを行うためのtsconfigを設定しましょう。
プロジェクトですでに使っているtsconfig.json
の他に、lint-staged
実行時に使う設定ファイル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
コマンドを実行します。
module.exports = {
'**/*.ts': (fileNames) => [
'tsc -p tsconfig-lint-staged.json --noEmit',
],
};
オプションについては、こちら(TypeScript: Documentation - tsc CLI Options)から確認ください。[2]
(余談)ESLintやPrettierによるフォーマットも同時に行いたい場合
配列に追加すれば順番に実行できます。
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
コマンドを実行します。
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のcompierOptions
をtsc
コマンドで使えるように変形します。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
ファイルを作成します。
const numberList = [5, 12, 8, 130, 44];
const found: number = numberList.find((element) => element > 10);
ステージングしないファイルも作ります。
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によるチェック・フォーマットも併せて行うことができるので、ぜひ活用してみてください。
読んでいただきありがとうございました。
また、一度誤った情報を掲載してしまい、申し訳ございませんでした。
検証をしっかりと行い、正しい情報を掲載するようにします。🙇♂️
参考サイト
- okonet/lint-staged
- husky - npm
- TypeScript: Documentation - tsc CLI Options
- 【JavaScript】コミットする前にlint-stagedでeslintのチェックをする - ざきの学習帳(旧 zackey推し )
- git commitしたときにTypeScriptコードを自動修正する方法(tslint, prettier, lint-staged使用) - Qiita
- error TS5042: Option 'project' cannot be mixed with source files on a command line. - lint-staged
-
LINE証券フロントエンドにおける型安全性への取り組み | LINE DEVELOPER DAY 2020を参考にさせていただきました。こちらではCI環境で型チェックを行い、
noImplicitAny
のエラーを減らしていったそうです。 ↩︎ -
今回の場合は、
tsc --strict --noEmit
でも代用可能です。 ↩︎
Discussion
これだと、実際に実行しているコマンドが
tsc -p tsconfig-lint-staged.json --noEmit
になるので staged されていないファイルも厳密な型チェックの対象になってしまっていませんか?ご指摘ありがとうございます。返信が遅くなり申し訳ありません。
もう一度調べてみたところ、ご指摘の通り、stagedされていないファイルも型チェックの対象となっておりました。検証が足りず、すみません。
他の方法を探ってみたいと思います。
タイトルの通り、stagedされているファイルのみ型チェックできるように記事の内容を修正しました。ありがとうございました。
すいません!質問です。
こちらの記事を参考に、型チェックの環境を作っていたのですが、ファイルAに依存するファイルBがあった時に(ファイルBでファイルAをimportしている)、ファイルBのみステージングすると、依存するファイルAも型チェックされる気がするのですが、どうでしょう?
※ 変更対象のファイルBに型エラーがなくても、importしているファイルAに型エラーがあるとコミット出来ない。
もし、回避策ご存知であればお願いします🙏🙏