ESLint の共有設定を Flat Config に対応させる

まえがき

こんにちは, 人間 ESLint です.

そんな人間 ESLint の私ですが, 私一人があらゆるコードに注意深く目を通して, さらに修正案まで提示するというのは大変です. 多くの問題は機械的に検知できるはずなので, そういった仕事を私の代わりにしてくれるメカ人間 ESLint が欲しくなってきます.

そんなわけで, 4 年ほど前から ESLint の共有設定を仕込み続けています. この共有設定を作るために, 私自身も ESLint の組み込みのルールtypescript-eslint が提供するルールを何度か全て見直したりしていて, それゆえの人間 ESLint でもあります.

github.com

Flat Config

ところで ESLint の設定ファイルは, 次のメジャーバージョンである v9 以降に新しい形式のもの (通称「Flat Config」) が標準となり, これまでの形式のもの (eslintrc) は非推奨とすることが計画されています. 現在の最新メジャーバージョンである v8 でも, すでに Flat Config の利用自体は可能となっています.

eslint.org

詳しい変更点としては上記のドキュメントや移行ガイドを読んでいただくとして, 例えば eslintrc での以下のような設定は,

"use strict";

module.exports = {
  // (余談: はたして overrides 以外を使うことがあるだろうか?)
  overrides: [
    // ソースファイル (TypeScript) 用の設定
    {
      // *.ts ファイルに対して,
      files: ["*.ts"],
      // 共有設定を拡張し,
      extends: [
        "@susisu/eslint-config/preset/ts",
        "prettier",
      ],
      // プロジェクトごとの設定を追加する.
      parserOptions: {
        ecmaVersion: "latest",
        sourceType: "module",
        project: "./tsconfig.json",
      },
      env: {
        es2021: true,
      },
    },
    // その他のファイル用の設定...
  ],
};

Flat Config では以下のような設定に置き換えられます. 大きな変更点として共有設定やプラグインなどのインポートが必要になったのもありますが, なにより extends による拡張 (入れ子構造) がなくなり, Flat Config という名前のとおり平坦な構造になっていることがわかります.

"use strict";

const { config } = require("@susisu/eslint-config");
const eslintConfigPrettier = require("eslint-config-prettier");
const globals = require("globals");

module.exports = [
  // ソースファイル (TypeScript) 用の設定
  {
    // *.ts ファイルに対して,
    files: ["**/*.ts"],
    // 共有設定を使用する.
    ...config.tsTypeChecked,
  },
  {
    // *.ts ファイルに対して,
    files: ["**/*.ts"],
    // 共有設定を使用する.
    ...eslintConfigPrettier,
  },
  {
    // *.ts ファイルに対して,
    files: ["**/*.ts"],
    // プロジェクトごとの設定を使用する.
    languageOptions: {
      ecmaVersion: "latest",
      sourceType: "module",
      parserOptions: {
        project: "./tsconfig.json",
      },
      globals: globals.es2021,
    },
  },
  // その他のファイル用の設定...
];

あるファイルに対して最終的に適用される設定は, 配列に含まれる設定のうち, filesignores がそのファイルにマッチしたものを, 上から順番に deep merge したものです. 簡単ですね.

共有設定をどのように使うべきか

というわけで早速 Flat Config に移行していきたいわけですが, 上記の設定例を見ると files の重複が目立ちます. これらは記述としても煩雑ですし, 意味的にも全て同じであってほしいので, 重複して書かれているのは好ましくありません.

解決案 1. 共通部分を分離する

重複している files の部分を別途定義して分離することで, 意味的な繰り返しは回避できます.

const forSourceFiles = {
  files: ["**/*.ts"],
};

module.exports = [
  // ソースファイル (TypeScript) 用の設定
  {
    // ソースファイルに対して,
    ...forSourceFiles,
    // 共有設定を使用する.
    ...config.tsTypeChecked,
  },
  {
    // ソースファイルに対して,
    ...forSourceFiles,
    // 共有設定を使用する.
    ...eslintConfigPrettier,
  },
  {
    // ソースファイルに対して,
    ...forSourceFiles,
    // プロジェクトごとの設定を使用する.
    languageOptions: {
      ecmaVersion: "latest",
      sourceType: "module",
      parserOptions: {
        project: "./tsconfig.json",
      },
      globals: globals.es2021,
    },
  },
  // その他のファイル用の設定...
];

ただし記述は引き続き繰り返しがあって煩雑なままですし, 別の変数として分離されていることで設定がやや読み取りづらくなっているような気もします.

解決案 2. さらに map で重複を除く

解決案 1 の同じ記述の繰り返しについては, 配列に対して map を使うことで取り除けます

module.exports = [
  // ソースファイル (TypeScript) 用の設定
  ...[
    // 共有設定を拡張し,
    config.tsTypeChecked,
    eslintConfigPrettier,
    // プロジェクトごとの設定を追加する.
    {
      languageOptions: {
        ecmaVersion: "latest",
        sourceType: "module",
        parserOptions: {
          project: "./tsconfig.json",
        },
        globals: globals.es2021,
      },
    },
  ].map((config) => ({
    // *.ts ファイルに対して.
    files: ["**/*.ts"],
    ...config,
  })),
  // その他のファイル用の設定...
];

これで繰り返しはなくなりましたが, map の都合によって記述の順番が逆になり, files が最後に書かれるようになってしまいました. あまり気にならない人もいるかもしれませんが, 私はこの場合は files が最初に書かれていた方がわかりやすいと思います.

解決案 3. さらにユーティリティ関数を定義する

解決案 2 の問題を解消するために, 記述の順番を反転させるユーティリティ関数を用意します.

function map(base, configs) {
  return configs.map((config) => ({ ...base, ...config }));
}

module.exports = [
  // ソースファイル (TypeScript) 用の設定
  ...map(
    {
      // *.ts ファイルに対して.
      files: ["**/*.ts"],
    },
    [
      // 共有設定を拡張し,
      config.tsTypeChecked,
      eslintConfigPrettier,
      // プロジェクトごとの設定を追加する.
      {
        languageOptions: {
          ecmaVersion: "latest",
          sourceType: "module",
          parserOptions: {
            project: "./tsconfig.json",
          },
          globals: globals.es2021,
        },
      },
    ],
  ),
  // その他のファイル用の設定...
];

これで記述の順番もわかりやすくなりましたが, 今度はユーティリティ関数を誰が用意するのか, という問題が生まれています. プロジェクトごとに定義したり別途ライブラリへの依存を追加するのは面倒ですし, 共有設定ごとに似たような関数が提供されるのもなんだか微妙そうです.

解決案 4. 設定項目を deep merge する

ここまでの案とは方向性が異なり, 旧来の eslintrc に存在した extends を再現するようなアイデアです.

複数の共有設定が languageOptionsrules といった項目を設定するため, 単純な shallow merge では上手くいきません. そのため (なんらかライブラリを使うなどして) deep merge をする必要があります.

module.exports = [
  // ソースファイル (TypeScript) 用の設定
  deepMerge(
    // *.ts ファイルに対して,
    {
      files: ["**/*.ts"],
    },
    // 共有設定を拡張し,
    config.tsTypeChecked,
    eslintConfigPrettier,
    // プロジェクトごとの設定を追加する.
    {
      languageOptions: {
        ecmaVersion: "latest",
        sourceType: "module",
        parserOptions: {
          project: "./tsconfig.json",
        },
        globals: globals.es2021,
      },
    },
  ),
  // その他のファイル用の設定...
];

たしかに重複はなくなりましたが, 解決案 3 と同様に, deep merge を行う関数を誰が用意するのかという問題が生まれています. また, 書き換える前の設定では ESLint が行っていた deep merge の方法と, 書き換えた後の deep merge の方法が一致しているかわからない (同じ意味の設定である保証がない) という懸念もあります.

結局どうしたか

共有設定を提供する立場で, 上記の課題をどう解決するかを考えたのですが, とりあえずは解決案 3 のように共通部分を展開するユーティリティ関数を, 設定と一緒に提供することにしました. この方法は独自に deep merge を行う場合のような, 設定の意味が変わってしまうといった懸念もないですし, もし将来的にエコシステムが発展して別の方法が定められたとしても, 単にこのユーティリティが使われなくなるだけで, 設計上の負債も残らないはずです.

"use strict";

// config だけでなく, ユーティリティ関数 map も提供される
const { config, map } = require("@susisu/eslint-config");
const eslintConfigPrettier = require("eslint-config-prettier");
const globals = require("globals");

module.exports = [
  // ソースファイル (TypeScript) 用の設定
  ...map(
    {
      // *.ts ファイルに対して.
      files: ["**/*.ts"],
    },
    [
      // 共有設定を拡張し,
      config.tsTypeChecked,
      eslintConfigPrettier,
      // プロジェクトごとの設定を追加する.
      {
        languageOptions: {
          ecmaVersion: "latest",
          sourceType: "module",
          parserOptions: {
            project: "./tsconfig.json",
          },
          globals: globals.es2021,
        },
      },
    ],
  ),
  // その他のファイル用の設定...
];

まとめ

ESLint の新しい設定ファイルの形式である Flat Config において, 共有設定をどのように使うべきか, あるいは共有設定をどのように提供すべきかについて考えました. とりあえずは共通部分を展開するようなユーティリティ関数を提供することで, Flat Config の枠組みの中で上手く共有設定を扱うことができそうです.