🎩

ESLintはv9に上げてからFlat Configに移行した方がやりやすそう

に公開

こんにちは!CastingONEの大沼です。

始めに

ESLint v9がリリースされてから1年くらい経ちました。リリース直後はFlat Configに対応していなかったり、そもそもv9の破壊的変更に追従できていないプラグインがいくつかあり苦労して移行された記事が散見されていましたが、流石に落ち着いたと思うので重い腰を上げてESLint v9のバージョンアップとFlat Configの移行をやりました。
段階的に移行する場合はv8の状態でFlat Configにしてからv9に上げるか、逆にv9に上げてからFlat Configに上げるというアプローチがありますが、どちらで進めるべきか分からず両方軽く試してみて簡単な方でやることにしました。結論としては今は先にv9に上げてからの方がFlat Configに移行しやすいと感じたのでその辺の理由や実際にやった作業について記事にまとめました。

v9に上げてからFlat Configにした方が良い理由

今v8でFlat Configにするには色々読み替えが必要

そもそも勘違いしていたのですが、v8では先頭にESLINT_USE_FLAT_CONFIG=trueをつけてESLintを実行すればFlat Configで実行できますが、v8であっても使えないオプションが存在します。例えば以下のようにすると--ext--ignore=pathが使えないと怒られます。

v8であってもFlat Configでは--extと--ignore-pathが使えない
{
  "scripts": {
    "lint": "ESLINT_USE_FLAT_CONFIG=true eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore --cache ."
  }
}

特に除外対象が難しく、そもそもFlat Configからドットファイル(e.g. .dotfile)もデフォルトでは対象になるため、対象ファイルを合わせるにはそれも除外する設定を書く必要があります。
FlatConfig側で設定する方法は公式に丁寧に説明されていますが、eslint/configはv9からしかimportできません。

https://eslint.org/docs/latest/use/configure/ignore

またFlat Configにマイグレーションするには npx @eslint/migrate-config .eslintrc.json を実行すると楽にマイグレーションできますが、この出力にも eslint/config を使っています。従ってこれをv8ではそのまま実行することができず適宜書き換えが必要になります。当たり前ですが最新のバージョンで動くことを想定した設定ファイルが出力されるため、一部だけ書き換えても他で問題が起きてしまったり、期待通り動かすのは相当骨が折れます。

マイグレーションの出力例
import { defineConfig } from 'eslint/config' // v9でしか使えない
import { fixupConfigRules, fixupPluginRules } from '@eslint/compat'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
import localRules from 'eslint-plugin-local-rules'
import lodash from 'eslint-plugin-lodash'
import _import from 'eslint-plugin-import'
import tsParser from '@typescript-eslint/parser'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import js from '@eslint/js'
import { FlatCompat } from '@eslint/eslintrc'

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const compat = new FlatCompat({
  baseDirectory: __dirname,
  recommendedConfig: js.configs.recommended,
  allConfig: js.configs.all,
})

export default defineConfig([
  {
    extends: fixupConfigRules(
      compat.extends(
        'eslint:recommended',
        'plugin:@typescript-eslint/recommended',
        'plugin:promise/recommended',
        'plugin:jest/recommended',
        'plugin:jest/style',
        'plugin:testing-library/react',
        'plugin:react/recommended',
        'plugin:react-hooks/recommended',
        'plugin:jsx-a11y/recommended'
      )
    ),
    plugins: {
      'simple-import-sort': simpleImportSort,
      'local-rules': localRules,
      lodash,
      import: fixupPluginRules(_import),
    },
    languageOptions: {
      parser: tsParser,
    },
    settings: {
      react: {
        version: 'detect',
      },
    },
    rules: {
      // 省略
    }
  },
  {
    files: ['**/*.js'],
    rules: {
      '@typescript-eslint/no-var-requires': 'off',
      '@typescript-eslint/no-require-imports': 'off',
    },
  },
  {
    files: ['**/*.test.ts', '**/*.test.tsx'],
    rules: {
      '@typescript-eslint/no-non-null-assertion': 'off',
      'testing-library/no-node-access': 'error',
    },
  },
  {
    files: ['apps/tenant/**', 'apps/staff/**'],
    extends: compat.extends('plugin:@next/next/recommended'),
    settings: {
      next: {
        rootDir: 'apps/*/',
      },
    },
    rules: {
      '@next/next/no-img-element': 'off',
    },
  },
])

v9で従来の設定を使って実行するのはESLINT_USE_FLAT_CONFIG=falseをつけるだけで良い

一方でv9から従来の設定を使って実行する場合は基本的に後方互換性を持っているのでESLINT_USE_FLAT_CONFIG=falseをつけることで実行することができます。

v9で従来の設定で実行する
{
  "scripts": {
    "lint": "ESLINT_USE_FLAT_CONFIG=false eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore --cache"
  }
}

実行した結果以下のエラーだけ出ましたが@next/eslint-plugin-nextのバージョンを最新に上げたら解決しました。

TypeError: context.getAncestors is not a function
Rule: "@next/next/no-duplicate-head"

ちなみにv9でFlat Configにした時も同じエラーが出ていたのでどの道対応する必要がありました。よって従来の設定のまま実行した方がv9の破壊的変更による対応が必要なものだけ出てきて、必要最小限の変更でバージョンを上げられると思います。

余談: ESLint v9の状態でVSCode上で従来の設定を使ってバリデーションする場合

ESLint v9ではVSCodeではFlat Configを使った実行がデフォルトになります。従来の設定で実行するには以下のオプションを設定する必要があります。

.vscode/settings.json
{
  "eslint.useFlatConfig": false,
}

このオプションの説明はhoverすると丁寧に書かれており、これを読んだ感じだと通常このオプションの設定が不要なのはESLintのバージョンによって良さげにtrue/falseを切り替えているからのようです。

移行手順

折角なので最終的にどういう手順で移行していったか記載します。

1. peerDependenciesがESLint v9が対象になるまで関連パッケージのバージョンを上げておく

弊社はnpmでパッケージを管理していますが、package-lock.jsonpeerDependenciesにESLintがv9も含まれるようになっていないとそもそもアップデート時にエラーになってしまいます。例えば以下のように "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" となっているとv9が対象になっていないのでエラーになります。

package-lock.jsonでESLint v9がサポートされていない部分を抜粋
{
  "packages": {
    "node_modules/eslint-plugin-jest": {
      "version": "25.7.0",
      "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-25.7.0.tgz",
      "integrity": "sha512-PWLUEXeeF7C9QGKqvdSbzLOiLTx+bno7/HC9eefePfEb257QFHg7ye3dh80AZVkaa/RQsBB1Q/ORQvg2X7F0NQ==",
      "dev": true,
      "dependencies": {
        "@typescript-eslint/experimental-utils": "^5.0.0"
      },
      "engines": {
        "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
      },
      "peerDependencies": {
        "@typescript-eslint/eslint-plugin": "^4.0.0 || ^5.0.0",
        "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
      },
      "peerDependenciesMeta": {
        "@typescript-eslint/eslint-plugin": {
          "optional": true
        },
        "jest": {
          "optional": true
        }
      }
    },
  }
}

eslint-plugin-jestについてはv28系まで上げるとESLint v9がpeerDependenciesに含まれるようになるのでそこまで上げます。

eslint-plugin-jest@28.14.0まで上げるとESLint v9もサポートされる
{
  "packages": {
    "node_modules/eslint-plugin-jest": {
      "version": "28.14.0",
      "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.14.0.tgz",
      "integrity": "sha512-P9s/qXSMTpRTerE2FQ0qJet2gKbcGyFTPAJipoKxmWqR6uuFqIqk8FuEfg5yBieOezVrEfAMZrEwJ6yEp+1MFQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@typescript-eslint/utils": "^6.0.0 || ^7.0.0 || ^8.0.0"
      },
      "engines": {
        "node": "^16.10.0 || ^18.12.0 || >=20.0.0"
      },
      "peerDependencies": {
        "@typescript-eslint/eslint-plugin": "^6.0.0 || ^7.0.0 || ^8.0.0",
        "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0",
        "jest": "*"
      },
      "peerDependenciesMeta": {
        "@typescript-eslint/eslint-plugin": {
          "optional": true
        },
        "jest": {
          "optional": true
        }
      }
    },
  }
}

これを全てのパッケージに対して対応します。

2. ESLintをv9に上げて従来の設定で実行して動くように調整する

ESLint v9に上げられる状態になったのでそのまま上げます。バージョンを上げたら先にv9に上げた方が良い理由の説明でも話しましたが、ESLINT_USE_FLAT_CONFIG=falseをつけて実行してエラーにならないように調整します。自分の場合は@next/eslint-plugin-nextのバージョンを最新に上げたらエラーなしで実行できるようになりました。なお、v9に上げるとrecommendedのルールが少し変わってlintエラーが出る場合があるのでそこは修正します。

v9で従来の設定で実行する
 {
   "scripts": {
-    "lint": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore --cache ."
+    "lint": "ESLINT_USE_FLAT_CONFIG=false eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore --cache ."
   }
 }

カスタムルールの修正

v9に上がったことでカスタムルール作成時の型がより厳密になったりテストのセットアップが少しだけ変わるのでそちらも変更します。
型の厳密化はifでtypeの絞り込みをする場合に更に細かくチェックしないと型が絞り込めなくなっただけなので条件を追加するだけで良いです。弊社のカスタムルールでは以下のような変更が必要でした。

typeチェックをより厳密にする
 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 = 'mdi-material-ui'
         if (!importLabel.startsWith(expectImportLabel)) {
           return
         }
 
         // mdi-material-ui/Home みたいなimportをしている場合
         if (importLabel !== expectImportLabel) {
           context.report({
             node,
             message: `ファイル指定によるimportは禁止しています。'mdi-material-ui'からimportしてください。`,
           })
           return
         }

         for (const specifier of node.specifiers) {
-          if (specifier.type !== 'ImportSpecifier') {
+          if (
+            specifier.type !== 'ImportSpecifier' ||
+            specifier.imported.type !== 'Identifier'
+          ) {
             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}`
                 )
               },
             })
           }
         }
       },
     }
   },
 }

テストについてはRuleTesterに渡すオプションのparserparserOptionslanguageOptionsの中に移動したのでそちらに移動します。
またparserをパスではなくimportした中身を渡す必要がありますが、tsconfig.jsonの設定よってはモジュールが読み込めないエラーが出るので注意が必要です。

しかしeslint-plugin-local-rulesで使うために事前にコンパイルしていた関係上あまり変更したくなかったので一旦requireで読み込むようにしました。

RuleTesterの設定をv9向けに変更する
 const ruleTester = new RuleTester({
-  parser: require.resolve('@typescript-eslint/parser'),
-  parserOptions: {
-    ecmaFeatures: {
-      jsx: true,
-    },
-  },
+  languageOptions: {
+    // tsconfig.jsonのmoduleがcommonjsで設定している関係上一旦requireで読み込む
+    parser: require('@typescript-eslint/parser'),
+    parserOptions: {
+      ecmaFeatures: {
+        jsx: true,
+      },
+    },
+  },
 })

3. Flat Configに移行する

まずは npx @eslint/migrate-config .eslintrc.json でFlat Configを自動生成します。その後、対象ファイルや除外ファイルの指定など従来のルールで実行したときと同じようになるための追加設定をします。ここでは以下の設定を追加しています。

マイグレーション後の追加設定
 // eslint.config.mjs
-import { defineConfig } from 'eslint/config'
+import { defineConfig, globalIgnores } from 'eslint/config'
 import {
+  includeIgnoreFile,
   fixupConfigRules,
   fixupPluginRules,
 } from '@eslint/compat'
 import simpleImportSort from 'eslint-plugin-simple-import-sort'
 import localRules from 'eslint-plugin-local-rules'
 import lodash from 'eslint-plugin-lodash'
 import _import from 'eslint-plugin-import'
 import tsParser from '@typescript-eslint/parser'
 import path from 'node:path'
 import { fileURLToPath } from 'node:url'
 import js from '@eslint/js'
 import { FlatCompat } from '@eslint/eslintrc'

 const __filename = fileURLToPath(import.meta.url)
 const __dirname = path.dirname(__filename)
 const compat = new FlatCompat({
   baseDirectory: __dirname,
   recommendedConfig: js.configs.recommended,
   allConfig: js.configs.all,
 })

+const gitignorePath = fileURLToPath(new URL('.gitignore', import.meta.url))

 export default defineConfig([
+  // .gitignoreに指定したファイルを除外する 
+  includeIgnoreFile(gitignorePath, 'Imported .gitignore patterns'),
+  // 先頭のドットファイルを無視する
+  globalIgnores(['**/.*']),
   {
+    linterOptions: {
+      // 不要なESLint disableコメントの警告をOFFにする
+      reportUnusedDisableDirectives: false,
+    },
+    // 対象ファイルを指定
+    files: ['**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx'],
     extends: fixupConfigRules(
       compat.extends(
         'eslint:recommended',
         'plugin:@typescript-eslint/recommended',
         'plugin:promise/recommended',
         'plugin:jest/recommended',
         'plugin:jest/style',
         'plugin:testing-library/react',
         'plugin:react/recommended',
         'plugin:react-hooks/recommended',
         'plugin:jsx-a11y/recommended'
       )
     ),
     plugins: {
       'simple-import-sort': simpleImportSort,
       'local-rules': localRules,
       lodash,
       import: fixupPluginRules(_import),
     },
     languageOptions: {
       parser: tsParser,
     },
     settings: {
       react: {
         version: 'detect',
       },
     },
     rules: {
       // 省略
     },
   },
   // 省略
 ])

後はnpmタスクをFlat Config向けに変えて完了です。

v9でFlat Configを使って実行する
 {
   "scripts": {
-    "lint": "ESLINT_USE_FLAT_CONFIG=false eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore --cache ."
+    "lint": "eslint --cache ."
   }
 }

保守性を上げる対応

以上でESLint v9に上げてFlat Configに変えることができましたが、できるだけ変更が少なくなるように暫定対応した箇所がいくつかありました。折角v9に上げたのでv9の機能を使ってより保守がしやすいように変更を加えます。

Flat ConfigをTypeScriptで動くようにする

Flat Configの設定ファイルはTypeScriptで書けるようになったのでeslint.config.mtsにリネームします。これを実行する場合はjitiの2系をinstallする必要があるのでinstallしておきます。installしておくとFlat ConfigがTypeScriptの場合内部で勝手にjitiで実行するようで特にnpmタスク側で何か追加設定する必要はありませんでした。

npm install -D jiti

ちなみにNode.jsがv22.10.0以上だとjitiなしで実行することもできるようです。まだexperimentalのようなので今回は使用しませんでしたが、気になる方は公式をご参照ください。

https://eslint.org/docs/latest/use/configure/configuration-files#typescript-configuration-files

設定ファイルがTypeScriptになったことでjitiは実行時はtypeチェックしてくれませんが、VSCode上ではエラーを確認することできるのでそこで問題ないか確認します。import時に型情報が読み込めないエラーがeslint-plugin-local-ruleseslint-plugin-lodashの2つあったくらいで、他は問題ありませんでした。前者はカスタムルールを直接importして使うようにしたら不要になるので一旦そのままにして、後者の方は最新にしても型情報がなさそうだったので// @ts-expect-errorアノテーションをつけてエラーを許容するようにしました。

カスタムルールを事前コンパイルせず直接importして使う

Flat Configではプラグインを直接importできるようになったのでeslint-plugin-local-rulesを経由せず直接importして使うように書き換えます。カスタムルールを直接importして使うやり方はこちらの記事を参考にしました。

https://zenn.dev/s_takashi/articles/ee7eae7ba80b62

弊社ではnpm workspaceを使っているため、@casone/eslint-custom-rulesパッケージでプラグインを用意して、eslint-plugin-local-rulesパッケージと差し替える形で書き換えました。名前も一緒にするとimport先を差し替えるだけで済みました。

eslint-plugin-local-rulesから自作のカスタムルールプラグインに差し替える
 import path from 'node:path'
 import { fileURLToPath } from 'node:url'

 import {
   fixupConfigRules,
   fixupPluginRules,
   includeIgnoreFile,
 } from '@eslint/compat'
 import { FlatCompat } from '@eslint/eslintrc'
 import js from '@eslint/js'
 import tsParser from '@typescript-eslint/parser'
 import { defineConfig, globalIgnores } from 'eslint/config'
 import _import from 'eslint-plugin-import'
+import localRules from '@casone/eslint-custom-rules'
-import localRules from 'eslint-plugin-local-rules'
 // @ts-expect-error 型情報が読み込めないので一旦エラーを許容する
 import lodash from 'eslint-plugin-lodash'
 import simpleImportSort from 'eslint-plugin-simple-import-sort'

Flat Configの暫定対応を整理する

compatを外す

fixupConfigRulesfixupPluginRulescompatを使っている部分はあくまで暫定対応なので、正しいFlat Configの書き方に適宜書き換えていきます。詳細はそれぞれのESLintプラグインの公式をご参照ください。

@typescript-eslint/parser@typescript-eslint/eslint-pluginの移行について

@typescript-eslint/parser@typescript-eslint/eslint-pluginを移行するのは結構難しいようなので、こちらの作業だけは書きます。以下の記事を参考にしながら対応しました。

https://zenn.dev/hsato_workman/articles/728e1551ab8b36#%40typescript-eslint-の移行

ただ新しくなったtypescript-eslint従来のパッケージを統合したものでdependenciesに含まれて中身もre-exportしているだけなので単純にパスが変わるだけでした。なので切り替える際は従来のパッケージのバージョンと合わせておくと良いです。自分のプロジェクトは8.38.0が使われていたのでそのバージョンでinstallします。

https://github.com/typescript-eslint/typescript-eslint/blob/v8.38.0/packages/typescript-eslint/package.json#L52-L57

参考にした記事では従来のパッケージをuninstallしていましたが、dependenciesに書かれているもので結局node_modulesから消えるわけじゃないのでこの作業はどちらでも良さそうでした。

このパッケージを使ってESLintの設定をすると以下のように書き換えます。折角なのでJavaScriptのrecommendもFlat Config向けに書き換えました。tseslint.configs.recommendeddefineConfigの型と微妙に合わないようなので一旦asキャストして渡しました。

@eslint/jsとtypescript-eslintからparserやrecommendを設定する
 // eslint.config.mjs
 import { defineConfig, globalIgnores } from 'eslint/config'
 import {
   includeIgnoreFile,
   fixupConfigRules,
   fixupPluginRules,
 } from '@eslint/compat'
+import type { ConfigWithExtendsArray } from '@eslint/config-helpers'
 import simpleImportSort from 'eslint-plugin-simple-import-sort'
 import localRules from '@casone/eslint-custom-rules'
 import lodash from 'eslint-plugin-lodash'
 import _import from 'eslint-plugin-import'
-import tsParser from '@typescript-eslint/parser'
+import tseslint from 'typescript-eslint'
 import path from 'node:path'
 import { fileURLToPath } from 'node:url'
 import js from '@eslint/js'
 import { FlatCompat } from '@eslint/eslintrc'

 const __filename = fileURLToPath(import.meta.url)
 const __dirname = path.dirname(__filename)
 const compat = new FlatCompat({
   baseDirectory: __dirname,
   recommendedConfig: js.configs.recommended,
   allConfig: js.configs.all,
 })

 const gitignorePath = fileURLToPath(new URL('.gitignore', import.meta.url))

 export default defineConfig([
   // .gitignoreに指定したファイルを除外する
   includeIgnoreFile(gitignorePath, 'Imported .gitignore patterns'),
   // 先頭のドットファイルを無視する
   globalIgnores(['**/.*']),
+  js.configs.recommended,
+  ...(tseslint.configs.recommended as ConfigWithExtendsArray),
   {
     linterOptions: {
       // 不要なESLint disableコメントの警告をOFFにする
       reportUnusedDisableDirectives: false,
     },
     // 対象ファイルを指定
     files: ['**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx'],
     extends: fixupConfigRules(
       compat.extends(
-        'eslint:recommended',
-        'plugin:@typescript-eslint/recommended',
         'plugin:promise/recommended',
         'plugin:jest/recommended',
         'plugin:jest/style',
         'plugin:testing-library/react',
         'plugin:react/recommended',
         'plugin:react-hooks/recommended',
         'plugin:jsx-a11y/recommended'
       )
     ),
     plugins: {
       'simple-import-sort': simpleImportSort,
       'local-rules': localRules,
       lodash,
       import: fixupPluginRules(_import),
     },
     languageOptions: {
-      parser: tsParser,
+      // 単純に削除するだけでも動くが、もし明示的に書くならtseslint.parserを渡す
+      parser: tseslint.parser,
     },
     // 省略
   },
   // 省略
 ])

テストの方もeslint-typescriptの方を使うと以下のように変えられます。ただeslintにあるRuleTesterとの型が合わなかったので一旦許容して渡しています。もしかしたら@typescript-eslint/rule-testerを使った方が良いのかもしれませんが、一旦はこれで進めます。ちなみにこの変更はtsconfig.jsonのmoduleがcommonjsのままでもimportできました。

typescript-eslintからparserを渡す
 import type { Rule } from 'eslint'
 import { RuleTester } from 'eslint'
+import tseslint from 'typescript-eslint'

 const ruleTester = new RuleTester({
   languageOptions: {
-    // tsconfig.jsonのmoduleがcommonjsで設定している関係上一旦requireで読み込む
-    parser: require('@typescript-eslint/parser'),
+    // @ts-expect-error ESLintのRuleTesterと型が合わないので一旦無視する
+    parser: tseslint.parser,
     parserOptions: {
       ecmaFeatures: {
         jsx: true,
       },
     },
   },
 })

暫定設定を厳密にする

従来の設定の挙動に合わせるためglobalIgnoresを使ってドットファイルの除外してましたが、.storybookディレクトリ配下のコードもlint対象できるので、除外対象はもっと限定的に調整すると良いです。
また不要なdisableコメント警告を抑制するreportUnusedDisableDirectivesも一旦OFFにしていましたが、こちらも当たり前ですが不要なdisableコメントは消えていた方が良いので確認して整理すると良いです。一つも出ないようになったらreportUnusedDisableDirectives: "error"にするとCIで落とすことができてより健全な状態を保てるようになると思います。

終わりに

以上がESLint v8からv9に上げてFlat Configに移行する方法でした。Flat Configにしてからv9に上げるか、v9に上げてからFlat Configにするか悩みながらの移行でしたが、結論現状では先にv9に上げてからFlat Configにした方がやりやすかったです。まだ移行できていない方の参考になれば幸いです。

Discussion