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
が使えないと怒られます。
{
"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できません。
また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',
},
},
])
ESLINT_USE_FLAT_CONFIG=false
をつけるだけで良い
v9で従来の設定を使って実行するのは一方でv9から従来の設定を使って実行する場合は基本的に後方互換性を持っているのでESLINT_USE_FLAT_CONFIG=false
をつけることで実行することができます。
{
"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を使った実行がデフォルトになります。従来の設定で実行するには以下のオプションを設定する必要があります。
{
"eslint.useFlatConfig": false,
}
このオプションの説明はhoverすると丁寧に書かれており、これを読んだ感じだと通常このオプションの設定が不要なのはESLintのバージョンによって良さげにtrue/falseを切り替えているからのようです。
移行手順
折角なので最終的にどういう手順で移行していったか記載します。
1. peerDependenciesがESLint v9が対象になるまで関連パッケージのバージョンを上げておく
弊社はnpm
でパッケージを管理していますが、package-lock.json
のpeerDependencies
にESLintがv9も含まれるようになっていないとそもそもアップデート時にエラーになってしまいます。例えば以下のように "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
となっていると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に含まれるようになるのでそこまで上げます。
{
"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エラーが出る場合があるのでそこは修正します。
{
"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の絞り込みをする場合に更に細かくチェックしないと型が絞り込めなくなっただけなので条件を追加するだけで良いです。弊社のカスタムルールでは以下のような変更が必要でした。
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
に渡すオプションのparser
とparserOptions
がlanguageOptions
の中に移動したのでそちらに移動します。
またparserをパスではなくimportした中身を渡す必要がありますが、tsconfig.json
の設定よってはモジュールが読み込めないエラーが出るので注意が必要です。
しかしeslint-plugin-local-rules
で使うために事前にコンパイルしていた関係上あまり変更したくなかったので一旦requireで読み込むようにしました。
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を自動生成します。その後、対象ファイルや除外ファイルの指定など従来のルールで実行したときと同じようになるための追加設定をします。ここでは以下の設定を追加しています。
- CLIの
--ext
オプションの移行-
files: ['**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx']
とglob形式で対象ファイルを指定する
https://eslint.org/docs/latest/use/configure/migration-guide#--ext
-
- CLIの
--ignore-path
オプションの移行-
includeIgnoreFile
を使って設定する
https://eslint.org/docs/latest/use/configure/ignore#including-gitignore-files
-
- ドットファイルを除外
- Flat Configではドットファイルもデフォルトだと対象になるため、
globalIgnores
を使って除外設定する
https://eslint.org/docs/latest/use/configure/ignore#ignoring-files
- Flat Configではドットファイルもデフォルトだと対象になるため、
-
// eslint-disable-next-line
などの一時的に無効化するコメントがそもそも設定が不要になっていた場合の警告を抑制- Flat Configからデフォルトで警告が出るようになったが、量がかなり多かったので一旦従来と同じ挙動になるようにOFFにする
https://zenn.dev/teppeis/articles/2023-12-eslint-report-unused-disable-directives
- Flat Configからデフォルトで警告が出るようになったが、量がかなり多かったので一旦従来と同じ挙動になるようにOFFにする
// 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向けに変えて完了です。
{
"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のようなので今回は使用しませんでしたが、気になる方は公式をご参照ください。
設定ファイルがTypeScriptになったことでjiti
は実行時はtypeチェックしてくれませんが、VSCode上ではエラーを確認することできるのでそこで問題ないか確認します。import時に型情報が読み込めないエラーがeslint-plugin-local-rules
とeslint-plugin-lodash
の2つあったくらいで、他は問題ありませんでした。前者はカスタムルールを直接importして使うようにしたら不要になるので一旦そのままにして、後者の方は最新にしても型情報がなさそうだったので// @ts-expect-error
アノテーションをつけてエラーを許容するようにしました。
カスタムルールを事前コンパイルせず直接importして使う
Flat Configではプラグインを直接importできるようになったのでeslint-plugin-local-rules
を経由せず直接importして使うように書き換えます。カスタムルールを直接importして使うやり方はこちらの記事を参考にしました。
弊社ではnpm workspaceを使っているため、@casone/eslint-custom-rules
パッケージでプラグインを用意して、eslint-plugin-local-rules
パッケージと差し替える形で書き換えました。名前も一緒にするとimport先を差し替えるだけで済みました。
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を外す
fixupConfigRules
やfixupPluginRules
、compat
を使っている部分はあくまで暫定対応なので、正しいFlat Configの書き方に適宜書き換えていきます。詳細はそれぞれのESLintプラグインの公式をご参照ください。
@typescript-eslint/parser
と@typescript-eslint/eslint-plugin
の移行について
@typescript-eslint/parser
と@typescript-eslint/eslint-plugin
を移行するのは結構難しいようなので、こちらの作業だけは書きます。以下の記事を参考にしながら対応しました。
ただ新しくなったtypescript-eslint
は従来のパッケージを統合したものでdependenciesに含まれて中身もre-exportしているだけなので単純にパスが変わるだけでした。なので切り替える際は従来のパッケージのバージョンと合わせておくと良いです。自分のプロジェクトは8.38.0
が使われていたのでそのバージョンでinstallします。
参考にした記事では従来のパッケージをuninstallしていましたが、dependenciesに書かれているもので結局node_modulesから消えるわけじゃないのでこの作業はどちらでも良さそうでした。
このパッケージを使ってESLintの設定をすると以下のように書き換えます。折角なのでJavaScriptのrecommendもFlat Config向けに書き換えました。tseslint.configs.recommended
がdefineConfig
の型と微妙に合わないようなので一旦asキャストして渡しました。
// 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できました。
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