TypeScript開発でのLintの現在について考える
ここでのLintの定義
- 静的解析によりソースコード内の問題点を指摘し、品質管理の補助をするツール
- 静的解析には型推論も含む(TypeScriptによる指摘も含む)
- 指摘するだけで自動修正はしなくても可
- フォーマッタのような機能を含んでも可
調査を始めた背景:ESLintとtypescript-eslintのパフォーマンス問題
JavaScriptのLinterとしてはESLintが有名で、プラグインによってTypeScriptにも対応している(typescript-eslint)。ただ残念なことにこの組み合わせにはパフォーマンスの問題があり、大規模なコードベースでは目に見えて処理に時間がかかってしまう。
私はMisskeyという分散型SNSのハードフォークを開発をしているが、私の開発環境[1]での、backendの全ファイルを対象としたコマンドラインからの実行には1回で30秒かかる。(問題のある部分が多いからかもしれないが……。)(frontendは5分近くかかるが、これはVue SFCのせいかもしれないのでとりあえず無視。)VS Code上での開いているファイルのLintにも、ぎりぎり不快なくらいのラグがある。もっといいものがあればぜひとも使いたい。
-
私の開発環境はM1 MacBook Airのみ。決して低スペックではないが、もっと強い環境で開発している人もたくさんいる。羨ましい。 ↩︎
Biomeやoxlintといった代替の現状:型情報Lintができない
ESLintの代替となり得る開発ツールとしてBiomeやoxcのoxlintがある。BiomeのLint機能はESLintの最大15倍速く、oxlintはESLintの50から100倍速いとされている。
しかし現状これらはTypeScriptの型情報をもとにしたLint(ESLintに対してtypescript-eslintがやっているようなもの)ができないというだいぶ大きな欠点を持っている。(そのため導入は検討段階で見送ってしまっていた。)
この欠点はTypeScript Compilerによる型情報の提供に時間がかかることが原因としてあるとされている。typescript-eslintがやっているようにTypeScript Compilerに依存する形で型情報を得ようとすると、せっかくの高いパフォーマンスが落ちてしまう。だからといって、TypeScript Compilerの代替を作るのも当然簡単なことではなく、挑戦はされているようだがまだ使える段階ではない。
(ここでは現状を整理したいので将来的に状況がどう変わるかについては考えないことにする。)
こちらの記事が詳しい:
比較的現実的な妥協案としての「併用」
型情報をもとにしたLintがESLintでしかできない状況がどうしてもあるため、ESLintをやめることはできない。しかし何も1つのツールしか使ってはいけないわけではなく、うまく組み合わせて使うことももちろん可能。Biomeやoxlintでも対応できる状況ではESLintを無効化することで、ESLintのパフォーマンスを高めることもできる。
Biomeの場合:マイグレーションコマンドが提供されている
BiomeとESLintの併用は公式にはサポートされていないようだった。BiomeのドキュメントにはESLintからのマイグレーションガイドはあったが、併用することは考えられていないように読める。
マイグレーション方法:
pnpm add --save-dev --save-exact @biomejs/biome
pnpm biome init
pnpm biome migrate eslint --write --include-inspired
Biomeのルールの命名規則はESLintのものとは異なっていて、Biomeを使う場合に無効化できるESLintルールはこちらの比較表を見るしかないと思われる。
oxlintの場合:プラグインでESLintのルールを無効化できる
一方oxlintはeslint-plugin-oxlintとして、ESLint側の重複するルールを無効化できるパッケージを提供している。
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import oxlint from "eslint-plugin-oxlint";
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.strictTypeChecked,
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
...oxlint.buildFromOxlintConfigFile("./.oxlintrc.json"),
);
これによりESLintには最小限のルールのみ処理させることができ、パフォーマンスの向上につながると思われる。
10日ほど前にcreate-vueがこの方法でのoxlintの活用を実験的にサポートしたらしい。
一方、追加でBiomeをサポートすることはメンテナンスコストを理由に断られている。(残念だけど仕方ないかな……。)
実際に併用してみる
ESLint(typescript-eslint)と同時にBiomeとoxlintを使い、それぞれ何がどこまでできるのか調べてみる。
各種設定
{
"type": "module",
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@eslint/js": "^9.14.0",
"@tsconfig/node-lts": "^22.0.0",
"@tsconfig/strictest": "^2.0.5",
"@types/eslint__js": "^8.42.3",
"@types/node": "^22.9.0",
"eslint": "^9.14.0",
"oxlint": "^0.11.1",
"typescript": "^5.6.3",
"typescript-eslint": "^8.14.0"
}
}
// TypeScriptの設定
// @tsconfig/strictestを使い、できる限り厳しくチェックする
{
"compilerOptions": {
"noEmit": true
},
"extends": [
"@tsconfig/strictest/tsconfig.json",
"@tsconfig/node-lts/tsconfig.json"
],
"include": ["src"]
}
// ESLintの設定
// eslint-plugin-oxlintはあえて使わず、重複する様子を確認してみる
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.strictTypeChecked,
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
);
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"categories": {
"correctness": "error",
"nursery": "off", // 実験的機能らしいので無効化
"suspicious": "error",
"pedantic": "error",
"perf": "error",
"restriction": "error",
"style": "error"
}
}
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"linter": {
"enabled": true,
"rules": {
"all": true // 実験的機能以外がすべて有効化される
}
},
"organizeImports": {
"enabled": true
}
}
例1:未使用の変数
// ts(6133)
// eslint(@typescript-eslint/no-unused-vars)
// biome(lint/correctness/noUnusedVariables)
// oxc(eslint(no-unused-vars))
const message = "Hello, world!";
@tsconfig/strictestでnoUnusedLocals
がtrue
に設定されているため、TypeScriptが指摘してくれている。typescript-eslintももちろんきちんと指摘できていて、Biomeとoxlintも動いている。eslint-plugin-oxlintでは@typescript-eslint/no-unused-vars
は無効化対象で、きちんとセットアップしておけば、普段の開発ではESLintのエラーは表示されなくなる。
as const
例2:不要な// eslint(@typescript-eslint/no-unnecessary-type-assertion)
const message = "Hello, world!" as const;
typescript-eslintしか対応できなかった。このtypescript-eslintのルールに影響を受けたルールがBiomeにも実装されているが、現在はジェネリクスにおけるT extends unknown
のようなものにしか対応していないようだ。
例3:不要なブロック
// eslint(no-empty)
// biome(lint/complexity/noUselessLoneBlockStatements)
{
}
ESLintとBiomeがエラーを報告した。ちなみにブロックの中にコメントを書くとESLintではエラーにならなくなる。どうしても細かな挙動は変わってきそうだ。
if
の条件に真偽値ベタ書き
例4:// eslint(no-constant-condition)
// eslint(@typescript-eslint/no-unnecessary-condition)
// biome(lint/correctness/noConstantCondition)
// oxc(eslint(no-constant-condition))
if (false) {
}
すべてのLinterがうまく動いた。しかしtypescript-eslintだけニュアンスが異なっていて、他3つがconstantな条件に対して怒っているのに対し、typescript-eslintは判定が常に同じになるため不要なことを指摘している。これが型情報の有無の差なのだろう。
Error
以外のthrow
例5:// eslint(@typescript-eslint/only-throw-error)
// biome(lint/style/useThrowOnlyError)
throw "ERROR!";
ESLintが動くのは想定内だったが、Biomeもうまく問題点を認識できている。ただしこれは次のような意地悪な書き方をすると認識できなくなる。
const message = "ERROR!";
throw message;
もしくは
throw () => {};
やはり型推論が必要になる場面では無理らしい。しかしそれでも最大限判定しようと頑張っているのはよい。
Promise
のawait
忘れ
例6:// eslint(@typescript-eslint/no-floating-promises)
Promise.resolve();
await
忘れはMisskeyでよくされてしまっている書き方で、これを報告してくれるのがtypescript-eslintだけであるためにESLintをやめられていないところがある。
例7:いろいろなエラー
fn();
let fn = <T extends unknown, U>(arg1?: T, arg2: any) => {
return arg2;
};
問題点をまとめると、
- 関数
fn
を宣言前に使っている(TypeScriptとBiomeが指摘) - そのうえ関数
fn
への引数の渡し方が間違っている(TypeScriptが指摘) - 関数
fn
はconst
で定義できるのにlet
を使って定義されている(ESLintとBiomeが指摘) - 型変数
T
が無駄にunknown
をextends
している(typescript-eslintとBiomeが指摘) - そもそも型変数
T
は一度しか使っていないのだから不要(typescript-eslintが指摘) - 型変数
U
に至っては使ってすらいない(TypeScriptとtypescript-eslintとBiomeが指摘) - 引数
arg1
がオプショナルであるにも関わらず後ろのarg2
がオプショナルでない(TypeScriptとBiomeとoxcが指摘) - そもそも引数
arg1
は使われていない(TypeScriptとtypescript-eslintとBiomeが指摘) - 引数
arg2
の型がany
(typescript-eslintとBiomeが指摘) - 関数
fn
がany
を返している(typescript-eslintが指摘)
ESLint(とtypescript-eslint)が賢いのは当然として、全体的にBiomeが健闘している印象がある。型推論こそできないものの、できる範囲でベストを尽くそうと頑張っているように見える。