Open5

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にも、ぎりぎり不快なくらいのラグがある。もっといいものがあればぜひとも使いたい。

脚注
  1. 私の開発環境はM1 MacBook Airのみ。決して低スペックではないが、もっと強い環境で開発している人もたくさんいる。羨ましい。 ↩︎

おかゆりぞっとおかゆりぞっと

Biomeやoxlintといった代替の現状:型情報Lintができない

ESLintの代替となり得る開発ツールとしてBiomeoxcoxlintがある。BiomeのLint機能はESLintの最大15倍速くoxlintはESLintの50から100倍速いとされている。

しかし現状これらはTypeScriptの型情報をもとにしたLint(ESLintに対してtypescript-eslintがやっているようなもの)ができないというだいぶ大きな欠点を持っている。(そのため導入は検討段階で見送ってしまっていた。)

この欠点はTypeScript Compilerによる型情報の提供に時間がかかることが原因としてあるとされている。typescript-eslintがやっているようにTypeScript Compilerに依存する形で型情報を得ようとすると、せっかくの高いパフォーマンスが落ちてしまう。だからといって、TypeScript Compilerの代替を作るのも当然簡単なことではなく、挑戦はされているようだがまだ使える段階ではない。

(ここでは現状を整理したいので将来的に状況がどう変わるかについては考えないことにする。)

こちらの記事が詳しい:

https://zenn.dev/kirohi/articles/3c644b614977fe

おかゆりぞっとおかゆりぞっと

比較的現実的な妥協案としての「併用」

型情報をもとにしたLintがESLintでしかできない状況がどうしてもあるため、ESLintをやめることはできない。しかし何も1つのツールしか使ってはいけないわけではなく、うまく組み合わせて使うことももちろん可能。Biomeやoxlintでも対応できる状況ではESLintを無効化することで、ESLintのパフォーマンスを高めることもできる。

Biomeの場合:マイグレーションコマンドが提供されている

BiomeとESLintの併用は公式にはサポートされていないようだった。BiomeのドキュメントにはESLintからのマイグレーションガイドはあったが、併用することは考えられていないように読める。

https://biomejs.dev/guides/migrate-eslint-prettier/

マイグレーション方法:

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側の重複するルールを無効化できるパッケージを提供している。

https://github.com/oxc-project/eslint-plugin-oxlint#readme

eslint.config.mjs
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の活用を実験的にサポートしたらしい。

https://github.com/vuejs/create-vue/pull/601

一方、追加でBiomeをサポートすることはメンテナンスコストを理由に断られている。(残念だけど仕方ないかな……。)

https://github.com/vuejs/create-vue/issues/608

おかゆりぞっとおかゆりぞっと

実際に併用してみる

ESLint(typescript-eslint)と同時にBiomeとoxlintを使い、それぞれ何がどこまでできるのか調べてみる。

各種設定
package.json
{
  "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"
  }
}
tsconfig.json
// TypeScriptの設定
// @tsconfig/strictestを使い、できる限り厳しくチェックする

{
  "compilerOptions": {
    "noEmit": true
  },
  "extends": [
    "@tsconfig/strictest/tsconfig.json",
    "@tsconfig/node-lts/tsconfig.json"
  ],
  "include": ["src"]
}
eslint.config.mjs
// 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,
      },
    },
  },
);
.oxlintrc.json
{
  "$schema": "./node_modules/oxlint/configuration_schema.json",
  "categories": {
    "correctness": "error",
    "nursery": "off", // 実験的機能らしいので無効化
    "suspicious": "error",
    "pedantic": "error",
    "perf": "error",
    "restriction": "error",
    "style": "error"
  }
}
biome.json
{
  "$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でnoUnusedLocalstrueに設定されているため、TypeScriptが指摘してくれている。typescript-eslintももちろんきちんと指摘できていて、Biomeとoxlintも動いている。eslint-plugin-oxlintでは@typescript-eslint/no-unused-varsは無効化対象で、きちんとセットアップしておけば、普段の開発ではESLintのエラーは表示されなくなる。

例2:不要なas const

// 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ではエラーにならなくなる。どうしても細かな挙動は変わってきそうだ。

例4:ifの条件に真偽値ベタ書き

// 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は判定が常に同じになるため不要なことを指摘している。これが型情報の有無の差なのだろう。

例5:Error以外のthrow

// eslint(@typescript-eslint/only-throw-error)
// biome(lint/style/useThrowOnlyError)
throw "ERROR!";

ESLintが動くのは想定内だったが、Biomeもうまく問題点を認識できている。ただしこれは次のような意地悪な書き方をすると認識できなくなる。

const message = "ERROR!";
throw message;

もしくは

throw () => {};

やはり型推論が必要になる場面では無理らしい。しかしそれでも最大限判定しようと頑張っているのはよい。

例6:Promiseawait忘れ

// 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が指摘)
  • 関数fnconstで定義できるのにletを使って定義されている(ESLintとBiomeが指摘)
  • 型変数Tが無駄にunknownextendsしている(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が指摘)
  • 関数fnanyを返している(typescript-eslintが指摘)

ESLint(とtypescript-eslint)が賢いのは当然として、全体的にBiomeが健闘している印象がある。型推論こそできないものの、できる範囲でベストを尽くそうと頑張っているように見える。