🕌

ESLintプラグインをTypeScriptで作る際にv8とv9の差を検証してみた

2024/09/23に公開

始めに

今までESLintのカスタムルールをTypeScriptで作って事前にコンパイルし、eslint-plugin-local-rulesに流し込んで運用してました。
小規模のプロジェクトであれば一緒に置くのは何も問題ないと思いますが、コードが増えるとパッケージとして切り出せるものは切り出したい気持ちが高まってきました。eslint-plugin-react-hooksのようにパッケージ化するとそっちのリポジトリでコンパイルやテストができるのでアプリケーション側のリポジトリはそこだけに集中できて良いですよね。

ただ最近はESLint v9がリリースされてFlat Configで大幅に変わってしまうためv9用で作ろうかなと思いましたが、残念ながらNext.jsのESLintルールが次のバージョンであるeslint-config-next@15であってもv9は指定されておらずv9系サポートに時間がかかりそうな雰囲気で、まだv8で動かしている可能性があるなと感じました。

https://github.com/vercel/next.js/blob/v15.0.0-canary.160/packages/eslint-config-next/package.json#L25

結論、今はまだ Next.js で flat config を使うのは待ったほうが良いと思います。(Next.js の対応を待ちましょう)

https://zenn.dev/hsato_workman/articles/0f10b04a25963c

v9に完全に移行するまでは待つべきか悩んでいましたが、そもそも今回のv8からv9のバージョンアップではconfigの渡し方が大幅に変わっただけでルールの作り方自体はそこまで変わらないのでは?と思ったので試しにv8とv9でプラグインを作ってみて、どういう変更が必要になるか確認してみました。

結論

先に結論を言うと、簡単なルールであれば全く作りが一緒だったのでv8として作っていてもv9でも引き続き問題なく使えそうでした。ただv9になるとpluginの設定は直接オブジェクトにできてeslint-plugin-local-rulesを経由する必要がなくなったり、TypeScriptで設定を書くこともできるようになりそうであんまりパッケージとして切り出す旨みが少なくなりそうだなと思いました。

https://blog.sa2taka.com/post/custom-eslint-rule-with-flat-config/

https://github.com/microsoft/vscode-eslint/issues/1917

作ったもの

今回作ったルールはiconをimportする際に~Iconとなるようにするものです。

~Iconにするルール
// OK
import { AccessAlarm as AccessAlarmIcon } from '@mui/icons-material'

// NG
import { AccessAlarm } from '@mui/icons-material'
import AccessAlarm from '@mui/icons-material/AccessAlarm' // 検証が手間だったのでNamedImportのみに制限する

v9のESLintではv8とv9のプラグインどちらも使えましたのでそれぞれのエラーがVSCodeで出ています。

ディレクトリ構成は、今回は簡易検証なのでnpm workspaceを使ってモノレポでv8とv9のESLintプラグインをそれぞれ作って、それらをimportして使いました。実運用ではeslint-plugin-local-v8などがパッケージとしてpublishしてそれをinstallして使うことを想定しています。

ディレクトリ構成
.
├── README.md
├── eslint-plugins (ESLintプラグインパッケージ群)
│   ├── eslint-plugin-local-v8(v8のESLintプラグイン)
│   └── eslint-plugin-local-v9(v9のESLintプラグイン)
├── eslint.config.js(v9のESLint設定)
├── package-lock.json
├── package.json
├── src(Reactアプリケーション)
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts

https://github.com/TakanoriOnuma/trial-eslint-custom-plugin

v8のプラグインはそもそもESLint v8の時に使えるものか確認するため、ブランチを切ってv8のパターンのコードも試しました。上記のディレクトリ構成のeslint.config.jseslintrc.cjsに変わります。

https://github.com/TakanoriOnuma/trial-eslint-custom-plugin/tree/eslint-v8_2

ESLint v8の場合

v8のESLintプラグインの基本構成

プラグインの作成は公式ドキュメントとeslint-plugin-react-hooksのコードを参考にしました。
https://eslint.org/docs/v8.x/extend/plugins

https://github.com/facebook/react/blob/v18.3.1/packages/eslint-plugin-react-hooks/src/index.js#L1-L26

これらを参考に、TypeScriptで書くと以下のような構成で書くと良さそうでした。

TypeScriptでプラグインを作る際の基本構成
import type { ESLint } from "eslint";

// ruleをimport
import { customRule } from "./rules/customRule";

const plugin = {
  configs: {
    // デフォルトで一括設定するプリセット(eslint:recommended的なやつ)
    // recommendedが不要な場合は未定義でも良い
    recommended: {
      // recommended内で自分のpluginsを設定すると自動でプラグインの登録もやってくれる(v8のみ。v9からpluginsの設定はおそらく非推奨)
      plugins: ["<プラグイン名>"],
      rules: {
        "<プラグイン名>/custom-rule": "warn",
      },
    },
  },
  // カスタムルールを定義
  rules: {
    "custom-rule": customRule,
  },
} satisfies ESLint.Plugin;

export = plugin;

v8でESLintプラグインの作成

今回パッケージ名は @local/eslint-plugin-local-v8 という名前にし、package.jsonは以下の通りです。

eslint-plugin-local-v8/package.json
{
  "name": "@local/eslint-plugin-local-v8",
  "private": true,
  "version": "0.0.0",
  "main": "dist/index.js",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.js"
    }
  },
  "scripts": {
    "build": "tsc",
    "test": "vitest run"
  },
  "devDependencies": {
    "@types/eslint": "8.40.2",
    "@types/node": "^22.5.5",
    "eslint": "8.43.0",
    "typescript": "5.3.3",
    "vitest": "^2.1.1"
  },
  "peerDependencies": {
    "eslint": ">=8"
  }
}

ESLintカスタムルールの作成は他でも記事がありましたので、そちらに譲ります。
https://blog.sa2taka.com/post/custom-eslint-rule-with-typescript/

最終的に出来上がったルールは以下です。

suffix-icon-import.ts
import type { Rule } from "eslint";

export const suffixIconImport: Rule.RuleModule = {
  meta: {
    type: "problem",
    fixable: "code",
  },
  create(context) {
    return {
      ImportDeclaration: (node) => {
        const importLabel = node.source.value;
        if (typeof importLabel !== "string") {
          return;
        }

        const expectImportLabel = "@mui/icons-material";
        if (!importLabel.startsWith(expectImportLabel)) {
          return;
        }
        // @mui/icons-material/AccessAlarmIcon みたいなimportをしている場合
        if (importLabel !== expectImportLabel) {
          context.report({
            node,
            message: `ファイル指定によるimportは禁止しています。'@mui/icons-material'からimportしてください。`,
          });
          return;
        }

        for (const specifier of node.specifiers) {
          if (specifier.type !== "ImportSpecifier") {
            continue;
          }
          const localName = specifier.local.name;
          const importedName = specifier.imported.name;
          const expectedLocalName = `${importedName}Icon`;
          if (localName !== expectedLocalName && specifier.loc != null) {
            context.report({
              loc: specifier.loc,
              message: "~Iconという名前で使用してください",
              fix(fixer) {
                return fixer.replaceText(
                  specifier,
                  `${importedName} as ${expectedLocalName}`
                );
              },
            });
          }
        }
      },
    };
  },
};

これをプラグインとして整理すると以下のようになりました。

v8のESLintプラグイン(./lib/index.ts)
import type { ESLint } from "eslint";

import { suffixIconImport } from "./rules/suffix-icon-import";

const plugin = {
  configs: {
    recommended: {
      plugins: ["@local/local-v8"],
      rules: {
        "@local/local-v8/suffix-icon-import": "warn",
      },
    },
  },
  rules: {
    "suffix-icon-import": suffixIconImport,
  },
} satisfies ESLint.Plugin;

export = plugin;

これを以下のようなtsconfig.jsonを使ってコンパイルしたら完成です。デバッグでsourceMapとか無駄なものを出力していますが、パッケージとしてpublishする際はfalseにしていた方が良いと思います。

tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "CommonJS",
    "outDir": "./dist",
    "esModuleInterop": true,
    "sourceMap": true,
    "declaration": true,
    "declarationMap": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["./lib/index.ts"]
}

v8のプラグインを実際に使う

プラグイン適用の際はeslint-plugin-をomitして設定できるため、以下のように書くことで設定できます。recommendedの中にpluginsの設定もあるので、extendsするだけで使用できるようになります。

https://eslint.org/docs/v8.x/use/configure/plugins

.eslintrc.cjsに作成したプラグインを追加する
 module.exports = {
   root: true,
   env: { browser: true, es2020: true },
   extends: [
     "eslint:recommended",
     "plugin:@typescript-eslint/recommended",
     "plugin:react-hooks/recommended",
+    "plugin:@local/local-v8/recommended",
   ],
   ignorePatterns: ["dist", ".eslintrc.cjs"],
   parser: "@typescript-eslint/parser",
   plugins: ["react-refresh"],
   rules: {
     "react-refresh/only-export-components": [
       "warn",
       { allowConstantExport: true },
     ],
+    // 上書きルール設定
+    "@local/local-v8/suffix-icon-import": "error",
   },
 };

v8のESLintカスタムルールのテスト

カスタムルールのテストは以下の記事を参考にVitestの設定をして、次のようなテストコードを書きました。

https://zenn.dev/s_takashi/articles/ee7eae7ba80b62#vitestの追加

test/suffix-icon-import.test.ts
import { RuleTester } from "eslint";

import { suffixIconImport } from "../lib/rules/suffix-icon-import";

const ruleTester = new RuleTester({
  parser: require.resolve("@typescript-eslint/parser"),
});

ruleTester.run("suffix-icon-import", suffixIconImport, {
  valid: [
    {
      code: "import { AccessAlarm as AccessAlarmIcon } from '@mui/icons-material'",
    },
  ],
  invalid: [
    {
      code: "import AccessAlarm from '@mui/icons-material/AccessAlarm'",
      errors: [
        {
          message:
            "ファイル指定によるimportは禁止しています。'@mui/icons-material'からimportしてください。",
        },
      ],
    },
    {
      code: "import { AccessAlarm } from '@mui/icons-material'",
      errors: [
        {
          message: "~Iconという名前で使用してください",
        },
      ],
      output:
        "import { AccessAlarm as AccessAlarmIcon } from '@mui/icons-material'",
    },
  ],
});

ESLint v9の場合

v9でESLintプラグインの作成

v9のESLintプラグインはバージョンを上げて以下のようなpackage.jsonになりました。

eslint-plugin-local-v9/package.json
 {
-  "name": "@local/eslint-plugin-local-v8",
+  "name": "@local/eslint-plugin-local-v9",
   "private": true,
   "version": "0.0.0",
   "main": "dist/index.js",
   "exports": {
     ".": {
       "import": "./dist/index.js",
       "require": "./dist/index.js"
     }
   },
   "scripts": {
     "build": "tsc",
     "test": "vitest run"
   },
   "devDependencies": {
-    "@types/eslint": "8.40.2",
-    "@types/node": "^22.5.5",
-    "eslint": "8.43.0",
-    "typescript": "5.3.3",
+    "@types/eslint": "^9.6.1",
+    "eslint": "^9.9.0",
+    "typescript": "5.5.3",
     "vitest": "^2.1.1"
   },
   "peerDependencies": {
-    "eslint": ">=8"
+    "eslint": ">=9"
   },
 }

プラグインの基本構成はほとんど変わりなく、pluginsの設定のみコメントアウトしておいた方が良さそうでした。カスタムルールについてはv8と全く同じコードで問題なかったので割愛します。

v9のESLintプラグイン(./lib/index.ts)
 import type { ESLint } from "eslint";

 import { suffixIconImport } from "./rules/suffix-icon-import";

 const plugin = {
   configs: {
     recommended: {
-      plugins: ["@local/local-v8"],
+      // pluginsは個別で設定するため、ここでは設定しない
+      // plugins: ["@local/local-v9"],
       rules: {
-        "@local/local-v8/suffix-icon-import": "warn",
+        "@local/local-v9/suffix-icon-import": "warn",
       },
     },
   },
   rules: {
     "suffix-icon-import": suffixIconImport,
   },
 } satisfies ESLint.Plugin;

 export = plugin;

v9のESLintプラグインを実際に使う

v9からはeslint.config.jsと言う名前に変わり、かつFlatConfigで設定するため、プラグインをimportして設定するようになります。recommendedのrulesはスプレッド演算で展開して使用するため、ルールを上書きしているのがより分かりやすくなったと思いました。ただpluginsに設定する名前には気をつける必要があって、今回のケースだと@local/local-v9と言う名前でpluginを登録しないとrecommendedで用意したルールとずれてしまうのがまだ暗黙的なところがあるなぁと思いました。ここも将来的には変更が入りそうな気がしました。

eslint.config.js
 import js from "@eslint/js";
 import globals from "globals";
 import reactHooks from "eslint-plugin-react-hooks";
 import reactRefresh from "eslint-plugin-react-refresh";
 import tseslint from "typescript-eslint";
+import localV9 from "@local/eslint-plugin-local-v9";

 export default tseslint.config(
   { ignores: ["dist"] },
   {
     extends: [
       js.configs.recommended,
       ...tseslint.configs.recommended,
     ],
     files: ["**/*.{ts,tsx}"],
     languageOptions: {
       ecmaVersion: 2020,
       globals: globals.browser,
     },
     plugins: {
       "react-hooks": reactHooks,
       "react-refresh": reactRefresh,
+      "@local/local-v9": localV9,
     },
     rules: {
       ...reactHooks.configs.recommended.rules,
+      ...localV9.configs.recommended.rules,
       "react-refresh/only-export-components": [
         "warn",
         { allowConstantExport: true },
       ],
+      // 上書きルール設定
+      "@local/local-v9/suffix-icon-import": "error",
     },
   }
 );

v8のESLintプラグインを使用する

単純にプラグインをimportして適切な場所にデータを渡すだけなので、v8のESLintプラグインも基本的には問題なく使えます。v9のESLintプラグインと同じ方法でも登録できますが、extendsの方で登録することも引き続きできたのでその方法を試してみました。ただしpluginsの設定が配列からkey-value形式に変わってしまったのでそこは書き換える必要がありました。こんな感じでextendsでも引き続き設定できますが、そもそもFlatConfigのコンセプトが暗黙的な登録をやめて明示的に書いていくことを目的としているはずなので基本的にはextendsは使わない運用が好ましいと思いました。

v8のESLintプラグインを使用する
 import js from "@eslint/js";
 import globals from "globals";
 import reactHooks from "eslint-plugin-react-hooks";
 import reactRefresh from "eslint-plugin-react-refresh";
 import tseslint from "typescript-eslint";
+import localV8 from "@local/eslint-plugin-local-v8";
 import localV9 from "@local/eslint-plugin-local-v9";

 export default tseslint.config(
   { ignores: ["dist"] },
   {
     extends: [
       js.configs.recommended,
       ...tseslint.configs.recommended,
+      {
+        ...localV8.configs.recommended,
+        // pluginsの設定がオブジェクト形式に変わったので、pluginsの設定を上書き
+        plugins: {
+          "@local/local-v8": localV8,
+        },
+      },
     ],
     files: ["**/*.{ts,tsx}"],
     languageOptions: {
       ecmaVersion: 2020,
       globals: globals.browser,
     },
     plugins: {
       "react-hooks": reactHooks,
       "react-refresh": reactRefresh,
       "@local/local-v9": localV9,
     },
     rules: {
       ...reactHooks.configs.recommended.rules,
       ...localV9.configs.recommended.rules,
       "react-refresh/only-export-components": [
         "warn",
         { allowConstantExport: true },
       ],
       // 上書きルール設定
+      "@local/local-v8/suffix-icon-import": "error",
       "@local/local-v9/suffix-icon-import": "error",
     },
   }
 );

v9のESLintカスタムルールのテスト

v9のESLintカスタムルールのテストは内容自体は全く同じで済みましたが、RuleTesterに渡すparserが変わりました。languageOptions.parserの方に移動して、かつimport先のpathではなく実際にメソッドを渡す必要になったので以下のように書き換えました。ただRuleTesterのデフォルトがTypeScriptに対応しているのか不明ですが、parserを設定しなくても今回のテストはpassできました。

test/suffix-icon-import.test.ts
 import { RuleTester } from "eslint";
+import tsParser from "@typescript-eslint/parser";

 import { suffixIconImport } from "../lib/rules/suffix-icon-import";

 const ruleTester = new RuleTester({
-  parser: require.resolve("@typescript-eslint/parser"),
+  // コメントアウトしても動きそう?
+  languageOptions: {
+    parser: tsParser,
+  },
 });

 // テストの中身は全く同じで済んだので省略

languageOptions.parserに設定する件はこちらでみました。

https://github.com/angular-eslint/angular-eslint/issues/1966#issuecomment-2310749913

その他

VSCodeのエディタ設定について

ESLint v9がリリース直後の頃はVSCodeではeslint.experimental.useFlatConfigを設定しないといけなかったのですが、今では不要になったようです。ESLint v8系でv8.57.0以上でFlatConfigにしている場合はeslint.useFlatConfigを設定する必要があるようです。

https://zenn.dev/gangannikki/articles/12bdae5236c846

終わりに

以上がESLintプラグインをTypeScriptで作る際に生じるv8とv9の差でした。簡単なプラグインだと変更がほぼないことが分かり、とりあえずv8から作っても問題なさそうということが分かって安心しました。また今までextendsやpluginの設定がどういうふうにされているか分からずとりあえずドキュメントの通りに書いていましたが、その辺の動きも理解できて良かったなと思いました。特にextendsの中にpluginの登録も入っていたのは驚きで、だからextendsだけ事足りるパターンとpluginsの方で設定しないといけないパターンがあったんだなと理解できました。
この記事がESLint Pluginを自作したい方の参考になれたら幸いです。

GitHubで編集を提案

Discussion