Flat Config導入完了! 新しいESLintの設定フォーマットを使ってみた

2022/12/09に公開
2

皆さんこんにちは。株式会社バベルでエンジニアをしている uhyo です。バベルが提供しているaileadというプロダクトでは TypeScript が多く使われており、ESLint も活用されています。この記事では、ailead のコードベースにおいて ESLint の新しい設定フォーマットであるFlat Config (eslint.config.js)を導入した事例を紹介します。

Flat Config とは?

Flat Config については、ESlint 公式ブログでも詳しく説明されています。

https://eslint.org/blog/2022/08/new-config-system-part-2/

ファイル名がeslint.config.js固定であり、必ず JavaScript で書かなければいけないのが特徴です。新しいフォーマットでは、従来の設定ファイル(.eslintrc{.yml,.json,.js})で辛かった部分が解消されています。特に、設定内容の解決にあたって ESLint 側の責務を小さくし、シンプルにまとめられています。ここではいくつか特徴的な所を紹介します。

ファイルシステム上をカスケードしない

従来の.eslintrcでは、ファイルシステム上の複数の.eslintrcを読み込んでマージするという挙動になることがあります。ファイルから見て直近の祖先ディレクトリにある.eslintrcだけでなく、さらにその親、またその親……というようにファイルシステム上が探索されます。ただし、.eslintrcroot: trueの設定があればそこで止まります。

この機構により、サブディレクトリ内のファイルに対して追加のルールを設定するといったことが可能になっていました。

Flat Config ではこのようなファイルシステム上のカスケードは廃止され、読み込まれるeslint.config.jsは直近の 1 つのみとなります。もし設定の再利用をしたい場合は、JavaScript であることを活かして他のファイルをimportすれば達成できます。また、もちろん JavaScript ですから、importされる個々のファイルは必ずしも ESLint のフォーマットに従う必要はなく、自由に分割できます。このように、設定の再利用に関するソリューションを ESLint 独自で持たずに JavaScript のエコシステムに寄せることでシンプルになっています。

プラグインを自分で解決しない

ESLint のエコシステムでは、plugin や preset という形で再利用可能な設定の開発が行われてきました。従来の.eslintrcでは、プラグイン等の解決を ESLint ランタイムが担っていました。

例えば、eslint-plugin-reactを導入したい場合は、.eslintrcでは次のようにプラグインを読み込みます。プラグインを読み込むことで、eslint-plugin-reactに同梱されたルールが利用可能になります。

{
  "plugins": ["react"],
  "rules": {
    "react/button-has-type": "error"
  }
  // ...他の設定...
}

このように、.eslintrcでは、JSON や YAML で設定を記述できるという関係もあり、読み込みたいプラグインを文字列で指定する必要がありました。また、名前に関してもルールがあり、上の例のように"react"とだけ書いた場合はeslint-plugin-reactを読み込むという意味になります。ということはnode_modulesからパッケージを読み込まないといけませんが、その解決元ディレクトリは.eslintrcがおいてあるディレクトリとなります。

新しい Flat Config では、これまた JavaScript であることを活かして設定を書く側でパッケージの解決を担当します。つまり、プラグインの読み込みは次にように行うことになります。

import react from "eslint-plugin-react";

export default [
  {
    plugins: {
      react
    },
  }
  {
    rules: {
      "react/button-has-type": "error"
    }
  },
  // ... 他の設定 ...
]

このように、Flat Config ではパッケージの解決が JavaScript のエコシステム(Node.js)に乗っかる形になっており、自身の責務を減らしています。

また、プラグインの解決がimportで行われる関係上、名前の暗黙の解決ルール("react""eslint-plugin-react"に解釈される)も廃止されており、パッケージのフルネームを書く必要があります。その裏返しとして、プラグインをeslint-plugin-という名前で始める必要がなく、自由な名前を付けることができます。このように、Flat Config ではパッケージの解決が JavaScript のエコシステム(Node.js)に乗っかる形になっており、自身の責務を減らしています。

フラットなカスケーディング

Flat Config という名の通り、新しいフォーマットはフラットなことが特徴です。これが意味するところは、eslint.config.jsがエクスポートすべきものがオブジェクトではなく配列であるということです。配列の要素は前から順番に適用されます。適用範囲はfilesで制御することができます。

export default [
  {
    // 全てのファイルに適用される設定
    rules: {
      /* ... */
    },
  },
  {
    // TSファイルに適用される設定
    files: ["*.ts", "*.mts", "*.cts"],
    languageOptions: {
      parser: typeScriptESLintParser,
      parserOptions: {
        project: true,
      },
    },
    rules: {
      /* ... */
    },
  },
];

このカスケーディングの機構は、従来の.eslintrcが持っていた 2 つの機構を統合するものです。つまり、extendsで再利用可能な設定を読み込む機構と、overridesにより一部のファイルのみ設定を変えられる機構です。両者はネスト可能(extendsをたどるとoverridesがあったり、overridesの中でextendsできたり)であったため、従来は設定ファイルのカスケーディングが木構造の上で行われていたことになります。

設定ファイルの解決機構としては、木構造とリスト構造を比べるとリスト構造のほうが簡潔です。木構造のときは、複雑な設定を書いた際にどのように解決されるのか良くわからず、筆者は自分が書いた ESLint の設定が最適なのかどうか判然としませんでした。リストならばルールがとても明確であり、リファクタリング等も自信をもって行うことができます。

このように、Flat Config は全体として覚えないといけない ESLint 特有のルールを減らしてくれるよい機構です。

Flat Config に移行しようと思った理由

この記事が公開された時点では、Flat Config は experimental という扱いになっています。ただし、CLI から ESLint を使用する場合はデフォルトでサポートされており、eslint.config.jsを配置するだけで Flat Config を利用できます。

筆者としては、このような実験に協力できるだけでも Flat Config に移行する価値はあると思っていますが、やはり業務でやることですから他にも理由が必要です。

背景として、ailead ではいわゆるモノレポが採用されており、ESLint の設定等もパッケージの数だけ存在しています。ルールを無駄にぶれさせないためには ESLint の設定は共通化されている必要があります。

従来は、共通化された設定をモノレポのルートに配置していました。個々のパッケージの.eslintrcはファイルシステムを通じてルートの共通設定を参照し、適宜拡張する形になっていました。

この度、このような設定ファイルをモノレポ内の 1 つのパッケージとして管理したいと考えるようになりました。今はbase-configという名前を付け、ESLint の設定のほかに tsconfig.json についても管理しています。というのも、ailead のモノレポではTurborepoを導入しており、タスクの結果をキャッシュしています。このキャッシュを最大限生かすためには、あるパッケージ内で実行されるタスクの結果はそのパッケージ内のファイルのみによって決まるのが望ましく、他のパッケージから受ける影響についてはその依存関係が package.json に明記されているべきです。モノレポのルートに配置された設定ファイルを読み込むというのは原則から逸脱しており、キャッシュ効率を落としてしまったり、誤ったキャッシュをしてしまったりする可能性があります。

ESLint の設定を共通化するにあたって、.eslintrcのままで行くか eslint.config.jsに変えるかという選択が生まれます。今回は、以下の点にメリットを感じたため eslint.config.js に移行することにしました。

  • ESLint の設定をメンテナンスできる人が多いほうが望ましく、前述のように Flat Config のほうがメンテナンスに必要な認知負荷が低い。
  • Flat Config のほうがモノレポ内のパッケージ名に自由度があり、リポジトリの管理上望ましい。

Flat Config への移行 Tips

従来の設定を Flat Config (eslint.config.js) に移行する方法は、ESLint の公式でも説明されています。しかし、実際にこれまで運用されていた共通設定を Flat Config に移行するにあたっては、多少工夫が必要でした。そこで、皆さんが Flat Config に移行する助けとなるべく、Tips をまとめて紹介します。

.eslintignore は消す

Flat Config では従来使用されていた .eslintignoreも廃止されています。無視してほしいファイルについても eslint.config.jsの中に記述することになります。

ESLint のドキュメントにもありますが、Flat Config の配列中にignoresだけを持つオブジェクトを加えることで、そこで指定されたファイルは完全に無視されるようになります。

export default [
  { ignores: ["dist", "**/*.generated.ts"] },
  // ... 他の設定 ...
];

ignoresの意味論はやや特殊で、filesと一緒に使った場合は意味が異なるので注意しましょう。

export default [
  {
    files: ["**/*.ts"],
    ignores: ["*.test.ts"],
    rules: {
      // *.test.tsには適用されない設定が書ける
    },
  },
];

このように eslint.config.js 内に ignore の情報が書けるというのは、.eslintignoreの共有も自然にできるようになるということなので嬉しい変更点ですね。

filesを明示的に書くと良さそう

従来の ESLint の設定の書き方では、特定のグループのファイルに対してのみ設定を追加する方法が overrides と呼ばれていたため、「デフォルトの設定 + 一部上書き」というメンタルモデルで構築された .eslintrc が多かったのではないかと思います。

Flat Config では配列の各要素に files を与えられるようになっているため、メンタルモデルが「最初からファイルをグループに切り分けてそれぞれにルールを書く」とういうように改められていると考えられます(filesを省略すれば依然として全ファイルに適用されるルールを書くことは可能です)。

例えば、ailead ではのっぴきならない事情により生の JavaScript が使われているパッケージと TypeScript が使われているパッケージがあるため、両方をサポートできる共通設定を用意する必要がありました。

従来のメンタルモデルにしたがって書かれた .eslintrc はおおよそこのような構成になっていました。

{
  "extends": [
    // JS・TSに共通のpresetたち
  ]
  "rules": {
    // JS・TSに共通のルールたち
  },
  "overrides": {
    // TSのみの設定たち
    "files": ["**/*.ts", "**/*.tsx"],
    "parser": "@typescript-eslint/parser",
    "parserOptions": {
      "ecmaVersion": 2018,
      "sourceType": "module",
      "project": "./tsconfig.json"
    },
    "rules": { /* ... */ }
  }
}

今回 Flat Config に移行するにあたって、同じことを次のように表現することにしました。

import typescriptEslintParser from "@typescript-eslint/parser";

const jsRules = {
  // JS・TSに共通のルールたち
};
const tsRules = {
  // TSのみのルールたち
};

export default [
  {
    files: ["**/*.js"],
    rules: jsRules,
  },
  {
    files: ["**/*.ts"],
    languageOptions: {
      // parserも自前で"@typescript-eslint/parser"からimportする
      parser: typescriptEslintParser,
      parserOptions: {
        ecmaVersion: 2018,
        sourceType: "module",
        project: "./tsconfig.json",
      },
    },
    rules: {
      ...jsRules,
      ...tsRules,
    },
  },
];

このようにすることで、ESLint 独自の機構に頼らない共通化を行い設定の読みやすさを向上させる狙いがあります。また、今の時代、JS ではなく TS をデフォルトとして扱いたいと思ったので、TS を JS の上に乗せるという構成をやめて対等な立場で扱う形にしました。

これが唯一の正解というわけではありませんが、個人的には設定を読む必要があった際の読みやすさに貢献していると思います。

他のモジュールへの依存に対処する

ESLint の設定は、多くの場合サードパーティのライブラリに依存しています。例えば、TypeScript のコードに対して ESLint を実行したい場合は@typescript-eslintから提供されているライブラリを使うことになります。

基本的にこれらのライブラリは Flat Config サポートを(まだ)意識して作られていません。そこで、ESLint ではそこをつなぐためのFlatCompatクラスを@eslint/eslintrcから提供してくれています。これを使うことで、Flat Config 未対応のライブラリを Flat Config に変換してくれます。

import { FlatCompat } from "@eslint/eslintrc";

const compat = new FlatCompat({
  baseDirectory: __dirname,
});

baseDirectoryを渡さなければいけない点が趣深いですね。前述の通り、.eslintrcでは ESLint がパッケージ解決を担っていたことの名残です。

このFlatCompatcompat.config({ ... })で従来の .eslintrc を丸ごと Flat Config に変換する機能も持っているため最悪これでもよいのですが、今回我々は Flat Config の利点を活かすため、FlatCompatの利用は必要最小限にしました。

plugin への依存

ESLint のライブラリにはいくつかの種類があります。そのひとつがプラグインであり、これは ESLint ルールを提供する役割を持っています。

// .eslintrcでの利用法
{
  "plugins": [
    "react" // eslint-plugin-reactを読み込むという意味
  ],
  "rules": {
    // eslint-plugin-reactから読み込んだルールを使用可能
    "react/button-has-type": "error"
  }
}

これは Flat Config でも簡単に可能です。一応compat.pluginsというのも提供されていますが、わざわざ使う必要はないでしょう。

// eslint.config.jsでの利用法
import react from "eslint-plugin-react";

export default [{
  plugins: {
    react,
  }
  rules: {
    // eslint-plugin-reactから読み込んだルールを使用可能
    "react/button-has-type": "error"
  }
}]

このように、顕著な違いとしては、plugin を読み込むところをこちらで行ってあげる必要があるところと、pluginsが配列ではなくオブジェクトになっていることです。ここがオブジェクトなのは Flat Config の新機能で、react/の部分も操作できるようになり自由度が上がっています。

// これも可能!
import react from "eslint-plugin-react";

export default [{
  plugins: {
    reaaaaaact: react,
  }
  rules: {
    "reaaaaaact/button-has-type": "error"
  }
}]

ちなみに、pluginsrulesは Flat Config 内の別々のオブジェクトにしても問題ありません。

// これもOK
import react from "eslint-plugin-react";

export default [
  {
    plugins: {
      react,
    },
  },
  {
    rules: {
      "react/button-has-type": "error",
    },
  },
];

config への依存

ESLint にはconfigと呼ばれる形態のライブラリもあります。これは、.eslintrcextendsで読み込まれることを前提として、プラグインやルールなど ESLint の設定一式を提供するものです。また、プラグインとして提供されているパッケージであっても、そのプラグインの推奨利用方法を config として提供している場合もあります。

これらのプラグインは現状では.eslintrc形式の設定をエクスポートしているため、さすがにFlatCompatのお世話になる必要があります。しかし、FlatCompatを使えば簡単です。

// .eslintrc
{
  "extends": [
    "plugin:node/recommended",
    // ...
  ]
}

// eslint.config.js
[
  ...compat.extends("plugin:node/recommended")
]

このようにcompat.extendsを使うと.eslintrcの動作をエミュレートしてくれます。ポイントは、compat.extendsは Flat Config に変換する(変換結果が配列である)ので、スプレッド演算子で均している点です。ESLint 側でもネストした配列を flatten してくれる気もしますが、折角なので自分で flatten しています。compat.extendsを使わない他の手としては、自分でimportしてしまってその結果をcompat.configに渡すのもよいでしょう。

プラグイン重複登録の罠

最後に、Flat Config への移行に際してひとつ詰まった点があったのでご紹介します。それは、@typescript-eslintから提供されているrecommended系のコンフィグを利用しようとした際に起こりました。

前節の説明にしたがってcompat.extendsを用いると次のような設定になります。

compat.extends(
  "plugin:@typescript-eslint/recommended",
  "plugin:@typescript-eslint/recommended-requiring-type-checking"
);

しかし、これを組み込んだ ESLint 設定を用意して実際に ESLint を実行すると、次のようなエラーが発生してしまいました。

TypeError: Key "plugins": Cannot redefine plugin "@typescript-eslint".

どうやら、Flat Config では同じ名前のプラグインを複数回登録できないところ、compat.extendsで 2 つ以上の config を extend してそれらが同じ名前のプラグインを追加しようとした場合にこの制約を踏んでしまうようです。

.eslintrcとの互換を達成できていないという意味ではこれはFlatConfigのバグと言えそうですが、直そうとしても意外と根が深そうな点と、どうせ@typescript-eslintが Flat Config に対応するまでのワークアラウンドにしかならないという点から、今回はeslint.config.jsを書く側のワークアラウンドで対処することにしました。

ワークアラウンドとしては、extendsが担う 2 つの責務(プラグインの追加と rules の設定)を別々に手動で行います。具体的には、次のようになります。

import typescriptEslint from "@typescript-eslint/eslint-plugin";

export default [
  {
    plugins: {
      "@typescript-eslint": typescriptEslint,
    },
    rules: {
      ...typescriptEslint.configs["eslint-recommended"].overrides[0].rules,
      ...typescriptEslint.configs["recommended-type-checked"].rules,
    },
  },
];

このように、プラグインからエクスポートされている config を読んでその rules を反映するところは手動で行いました。また、実はrecommendedrecommended-type-checkedは内部的にeslint-recommendedに依存しており、このワークアラウンドを使う場合はそれも明示的に追加する必要があります。

この対処は@typescript-eslint/eslint-pluginの内部構造にかなり依存しているので理想的ではありませんが、ワークアラウンドとしては十分だろうと判断しました。

CommonJS も使えるよ!

この記事では eslint.config.js を ES Modules を使用して書いてきました。今は Node.js も ES Modules をサポートしているということで、ESLint のドキュメントでも ES Modules が使用されています。

しかし、何やかんやの事情で eslint.config.jsを CommonJS で書きたかったので試してみたところ、CommonJS でも大丈夫でした。具体的にはこのような形です。

module.exports = [
  // Flat Config...
];

もし CommonJS ファンの場合も Flat Config を利用可能です。安心しましょう。

日本最速!?

この記事を公開した時点では、まだ Flat Config をプロダクションに導入したという話はあまり聞きません。

それもそのはずでしょう。なぜなら、VSCode の ESLint 拡張機能が Flat Config に対応していなかったからです。現在 Flat Config は experimental 扱いの機能であり、CLI からは普通に使える一方で、API を使用する場合はeslint/use-at-your-own-riskという怪しいエンドポイントから import する必要があります。そのため、VSCode の ESLint 拡張機能にもそのような対応が必要でした。

JavaScript プロジェクトにおいて VSCode が広い人気を得ている現状では、VSCode のサポートが無いままで Flat Config の導入に踏み切るのはなかなか難しいはずです(WebStorm という勢力もありますが、筆者がドキュメントを確認した限りだとまだ Flat Config には言及していませんでした)。

筆者が Flat Config を導入しようと決めた段階では、まだ VSCode の ESLint 拡張によるサポートは無く、予定もされていませんでした(ちなみに、VSCode の ESLint 拡張をメンテナンスしているのは Microsoft です。多分 VSCode を普及させるために作ったのでしょう)。

そこで、ailead プロジェクトへの Flat Config 導入を進めるために、筆者が VSCode の ESLint 拡張に Flat Config サポートを実装しました。ただ、実はTypeScriptのようなカスタムパーサーが必要なファイルへの対応がこれだけだとできていませんでした。その対応のためにアーキテクチャレベルで追加の意思決定が必要なところがあり、結局メンテなの方にその部分は実装までやっていただきました。

そして、これがマージされて Flat Config をサポートした拡張がリリースされた週のうちに、ailead のリポジトリに Flat Config 対応をマージしました。これなら多分日本最速導入と言えるのではないでしょうか。

ちなみに、Flat Configがexperimental扱いであるという事情から、VSCodeのESLint拡張でFlat Configサポートを有効化するには eslint.experimental.useFlatConfigというオプションをtrueにする必要があります。また、拡張の最新バージョンは2.3.0ですがこれはプレリリースという扱いです。利用する際にはご注意ください。

まとめ

この記事では、ESLint の新しい設定フォーマットである Flat Config をプロダクションのコードベースに導入した話を紹介しました。筆者の努力もあり、これをお読みのみなさんもすぐに Flat Config を導入することができるでしょう。

Flat Config は従来の.eslintrcと比較して、主に設定のメンテナンス性の面で有利だと考えています。みなさんもぜひ導入してみてください。

(2023/10更新)コピペで使えるFlat Config設定例

現在(2023年10月)の状況をベースに、コピペで使えるFlat Config設定例を紹介します。TypeScriptファイルのみが対象で、ESLint本体、eslint-config-prettier@typescript-eslint/eslint-pluginを使うものです。

// eslint.config.js
import js from "@eslint/js";
import eslintConfigPrettier from "eslint-config-prettier";
import tsEsLintPlugin from "@typescript-eslint/eslint-plugin";
import tsEsLintParser from "@typescript-eslint/parser";

export default [
  // 無視するファイルを指定(従来の .eslintignore に相当)
  { ignores: ["dist"] },
  // eslint:recommendedに相当
  js.configs.recommended,
  // eslint-config-prettierはrulesを持つオブジェクトなので、ここに並べられる
  eslintConfigPrettier,
  // プラグインを登録
  {
    plugins: {
      "@typescript-eslint": tsEsLintPlugin,
    },
  },
  // TypeScript用の設定
  {
    files: ['**/*.ts', '**/*.tsx', '**/*.cts', '**/*.mts'],
    languageOptions: {
      parser: tsEsLintParser,
      parserOptions: {
        project: true,
      },
    },
    rules: {
      // @typescript-eslint/eslint-pluginに付属のルールを適用
      ...tsEsLintPlugin.configs["eslint-recommended"].overrides[0].rules,
      ...tsEsLintPlugin.configs["recommended-type-checked"].rules,
      // 追加の設定
      "@typescript-eslint/no-explicit-any": "error",
    }
  }
];
Babel, Inc. Tech Blog

Discussion

StathamStatham

良記事ありがとうございます。さらっと把握するのに助かりました。

FYI:
2023年8月4日現在では、parser の指定は filesrules と同列ではないようですね。
languageOptions.parser と変わったようです。(公式をきちんと読もうって話ですが、記事中のコードをそのまま真似ると動かないので念の為コメント
https://eslint.org/docs/latest/use/configure/configuration-files-new#configuration-objects

また話が本筋とは逸れますが、@typescript-eslint/eslint-plugin の recommended 系に関しては v6 では色々リネームが入り、また recommended-type-checkedrecommended の全てを含むようなので指定が減らせて嬉しいですね。(eslint-recommended は方向性が別物なので依然として必要ですが)

https://typescript-eslint.io/blog/announcing-typescript-eslint-v6/#reworked-configuration-names

https://typescript-eslint.io/linting/configs/#strict-type-checked

uhyouhyo

情報ありがとうございます! 🙂 ちょうど我々もtypescript-eslintのv6に移行したところだったので、記事を更新しました。