ESLintプラグインをTypeScriptで作る際にv8とv9の差を検証してみた
始めに
今まで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で動かしている可能性があるなと感じました。
結論、今はまだ Next.js で flat config を使うのは待ったほうが良いと思います。(Next.js の対応を待ちましょう)
v9に完全に移行するまでは待つべきか悩んでいましたが、そもそも今回のv8からv9のバージョンアップではconfigの渡し方が大幅に変わっただけでルールの作り方自体はそこまで変わらないのでは?と思ったので試しにv8とv9でプラグインを作ってみて、どういう変更が必要になるか確認してみました。
結論
先に結論を言うと、簡単なルールであれば全く作りが一緒だったのでv8として作っていてもv9でも引き続き問題なく使えそうでした。ただv9になるとpluginの設定は直接オブジェクトにできてeslint-plugin-local-rules
を経由する必要がなくなったり、TypeScriptで設定を書くこともできるようになりそうであんまりパッケージとして切り出す旨みが少なくなりそうだなと思いました。
作ったもの
今回作ったルールはiconをimportする際に~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
v8のプラグインはそもそもESLint v8の時に使えるものか確認するため、ブランチを切ってv8のパターンのコードも試しました。上記のディレクトリ構成のeslint.config.js
がeslintrc.cjs
に変わります。
ESLint v8の場合
v8のESLintプラグインの基本構成
プラグインの作成は公式ドキュメントとeslint-plugin-react-hooks
のコードを参考にしました。
これらを参考に、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は以下の通りです。
{
"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カスタムルールの作成は他でも記事がありましたので、そちらに譲ります。
最終的に出来上がったルールは以下です。
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}`
);
},
});
}
}
},
};
},
};
これをプラグインとして整理すると以下のようになりました。
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にしていた方が良いと思います。
{
"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するだけで使用できるようになります。
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の設定をして、次のようなテストコードを書きました。
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になりました。
{
- "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と全く同じコードで問題なかったので割愛します。
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で用意したルールとずれてしまうのがまだ暗黙的なところがあるなぁと思いました。ここも将来的には変更が入りそうな気がしました。
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は使わない運用が好ましいと思いました。
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できました。
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
に設定する件はこちらでみました。
その他
VSCodeのエディタ設定について
ESLint v9がリリース直後の頃はVSCodeではeslint.experimental.useFlatConfig
を設定しないといけなかったのですが、今では不要になったようです。ESLint v8系でv8.57.0以上でFlatConfigにしている場合はeslint.useFlatConfig
を設定する必要があるようです。
終わりに
以上がESLintプラグインをTypeScriptで作る際に生じるv8とv9の差でした。簡単なプラグインだと変更がほぼないことが分かり、とりあえずv8から作っても問題なさそうということが分かって安心しました。また今までextendsやpluginの設定がどういうふうにされているか分からずとりあえずドキュメントの通りに書いていましたが、その辺の動きも理解できて良かったなと思いました。特にextendsの中にpluginの登録も入っていたのは驚きで、だからextendsだけ事足りるパターンとpluginsの方で設定しないといけないパターンがあったんだなと理解できました。
この記事がESLint Pluginを自作したい方の参考になれたら幸いです。
Discussion