自分だけの ESLint Config を作る

2024/07/02に公開

これはなに

自身のプロジェクトに適合した ESLint Config の設計および実装方法についてまとめたものです。

業務や趣味プロに関わらず、 JavaScript / TypeScript でコーディングする際に ESLint は非常に有用なツールであり、広く使われています。ほとんどの場合において ESLint 基盤はプロジェクトごとに必要なパッケージをインストールして設定ファイルを作成し、構築されます。しかしその設定内容がプロジェクト間で重複することは珍しくなく、プロジェクトの数が増えるにつれ冗長となりがちです。そこで、複数プロジェクト間で共通のルールセットを定義して単一の ESLint Config として再利用することで、メンテナンス性や再利用性の向上が期待できます。本稿では、そのような用途で使える自分だけの ESLint Config を設計・実装する方法を紹介します。

前提

  • Flat Config で設定する
  • ES Modules に準拠する

ESLint Config の設計

提供する機能を決める

想定する利用シーンを考慮して提供する機能を決めます。本稿では以下の 4 機能を提供することを想定します。

  • JavaScript のセオリーに準拠したコーディングに関する設定を提供する
  • Node.js のセオリーに準拠したコーディングに関する設定を提供する
  • React を用いたコーディングに関する設定を提供する
  • TypeScript でのコーディングに関する設定を提供する

全ての利用プロジェクトが上記の技術スタックを採用するならまだしも、なかには React や TypeScript を採用しないプロジェクトがある場合も考えられます。そのため、上記の内容を単一のルールセットとするのではなく、それぞれの設定を個別に提供できるようにするのが望ましいでしょう。以後はこれを前提に進めます。

ルールセットをコンテキストごとに分類する

提供する機能が決まったら、各ルールセットをコンテキストごとに分類します。ESLint のルール数はプラグインを含めると膨大な数になるため、コンテキストごとに分類することでルールセットのメンテナンス性を保持しやすくなります。本稿では以下のように設計します。

.
└── /rules
    ├── best-practices.js # eslint
    ├── errors.js
    ├── es6.js
    ├── style.js
    ├── variables.js
    ├── imports.js       # eslint-plugin-import
    ├── promise.js       # eslint-plugin-promise
    ├── node.js          # eslint-plugin-n
    ├── typescript.js    # @eslint-typescript/{eslint-plugin,parser}
    ├── jsx-a11y.js      # eslint-plugin-jsx-a11y
    ├── react-hooks.js   # eslint-plugin-react-hooks
    └── react.js         # eslint-plugin-react

eslint 本体のルールが best-practiceerrorses6stylevariables に分類されています。この分類は eslint-config-airbnb-base の設計を参考にしたものです。また、プラグインに依存するルールセットは、プラグインごとにコンテキストを分けてファイル化します。

分類したルールセットを提供する単位でグルーピングする

先述したルールセットはあくまでこの ESLint Config のメンテナンス性を高めるためのものであり、利用者が直接参照するにはいささか粒度が細かすぎます。そこで、コンテキストごとに分類したルールセットを利用者へ提供する単位でグルーピングします(ここではグルーピングしたものを config と呼びます)。これにより、利用者が必要なルールセットを選択しやすくなります。本稿では以下のようなグルーピングで設計します。

.
└── /configs
    ├── essentials.js # best-practices, errors, es6, imports, promise, style, variables
    ├── node.js       # node
    ├── react.js      # jsx-a11y, react, react-hooks
    └── typescript.js # typescript
Config 依存するルールセット 説明
essentials best-practices
errors
es6
imports
promise
style
variables
JavaScript のセオリーに準拠したコーディングに関する設定
node node Node.js のセオリーに準拠したコーディングに関する設定
react jsx-a11y
react-hooks
react
React を用いたコーディングに関する設定
typescript typescript TypeScript でのコーディングに関する設定

このようにグルーピングすることで、利用者はプロジェクトに合わせて必要な設定を選択できるようになります。例えば最低限の設定だけを適用したい場合は essentials のみを選択し、React と TypeScript を用いたプロジェクトであればさらに reacttypescript を追加するといった具合です。

インターフェイスを設計する

提供する設定が決まったら、利用者がそれらをどのような形で参照するかを設計します。本稿における ESLint Config は Flat Config かつ ES Modules に準拠することを前提としているため、以下のようなインターフェイスを設計します。

eslint.config.js
import { essentials, node, react, typescript } from './configs';

export default [...];
.
└── /configs
+   ├── index.js
    ├── essentials.js # best-practices, errors, es6, imports, promise, style, variables
    ├── node.js       # node
    ├── react.js      # jsx-a11y, react, react-hooks
    └── typescript.js # typescript

このように、グルーピングした設定をモジュールとして単一のファイル(ここでは configs.js とする)に集約し、利用者は必要な設定をインポートすることで利用できるようにします。各モジュールを named export とすることで、利用者は IDE による入力補完の恩恵を最大限に受けることができます。

最終的なディレクトリー構成
.
├── /rules
│   ├── best-practices.js
│   ├── errors.js
│   ├── es6.js
│   ├── imports.js
│   ├── node.js
│   ├── promise.js
│   ├── react-hooks.js
│   ├── react.js
│   ├── style.js
│   ├── typescript.js
│   └── variables.js
├── /configs
│   ├── essentials.js
│   ├── node.js
│   ├── react.js
│   └── typescript.js
└── package.json

実装

ルールセットを実装する

設計が完了したら各種ルールセットを実装します。

best-practices / errors / es6 / style / variables

これらは ESLint プラグインに依存しないため、eslint 本体のルールをそれぞれのコンテキストに分類して実装します。例えば best-practices は以下のように実装できます。

rules/best-practices.js
export default {
  rules: {
    'accessor-pairs': ['off'],
    'array-callback-return': ['error', { allowImplicit: true }],
    'block-scoped-var': ['error'],
    // ...
  },
};

imports / promise / node / react / react-hooks / jsx-a11y / typescript

importseslint-plugin-import に依存するため、プラグインをインストールして実装します。また、 settings フィールドも併せて設定します。

rules/imports.js
import importPlugin from 'eslint-plugin-import';

export default {
  plugins: {
    import: importPlugin,
  },
  settings: {
    'import/resolver': {
      node: {
        extensions: ['.mjs', '.js', '.json', '.ts'],
      },
      // Resolve the problem of incorrect recognition of alias paths by TypeScript compiler options.
      // https://github.com/import-js/eslint-plugin-import/issues/1485#issuecomment-535351922
      typescript: {},
    },
    'import/extensions': ['.js', '.mjs', '.jsx', 'ts', 'tsx'],
    'import/core-modules': [],
    'import/ignore': [
      'node_modules',
      '\\.(coffee|scss|css|less|hbs|svg|json|jpg|jpeg|png|webp)$',
    ],
    // TODO: Remove this once eslint-plugin-import supports Flat Config completely.
    // https://github.com/import-js/eslint-plugin-import/issues/2556#issuecomment-1419518561
    'import/parsers': {
      espree: ['.js', '.mjs', '.jsx', 'ts', 'tsx'],
    },
  },
  rules: {
    ...importPlugin.configs.recommended.rules,
    ...importPlugin.configs.errors.rules,
    // 追加のルール例
    'import/no-unresolved': ['error', { commonjs: true, caseSensitive: true }],
    // ...
  },
};

eslint-plugin-import が提供する recommendederrors ルールセットをベースにしたい場合は、以下のように Spread Operator を用いて取り入れ、そのうえで追加のルールを記述します。

rules: {
  ...importPlugin.configs.recommended.rules,
  ...importPlugin.configs.errors.rules,
  // 追加のルール例
  'import/no-unresolved': ['error', { commonjs: true, caseSensitive: true }],
  // ...
},

その他のルールセットも必要に応じて languageOptionsparserOptions などの設定を追加して実装しますが、基本的には上記の imports と同様の設計で問題ありません。

promise コード例
rules/promise.js
import promisePlugin from 'eslint-plugin-promise';

export default {
  plugins: {
    promise: promisePlugin,
  },
  rules: {
    ...promisePlugin.configs.recommended.rules,
    // 追加のルール例
    'promise/always-return': ['off', { ignoreLastCallback: true }],
    // ...
  },
};
node コード例
rules/node.js
import n from 'eslint-plugin-n';

export default {
  plugins: {
    n,
  },
  rules: {
    ...n.configs['flat/recommended'].rules,
    // 追加のルール例
    'n/global-require': ['error'],
    // ...
  },
};
react コード例
rules/react.js
import react from 'eslint-plugin-react';

export default {
  plugins: {
    react,
  },
  languageOptions: {
    ...react.configs.recommended.languageOptions,
  },
  settings: {
    react: {
      version: 'detect',
    },
  },
  rules: {
    ...react.configs.recommended.rules,
    // 追加のルール例
    'react/display-name': ['off', { ignoreTranspilerName: false }],
    // ...
  },
};
react-hooks コード例
rules/react-hooks.js
import reactHooks from 'eslint-plugin-react-hooks';

export default {
  plugins: {
    'react-hooks': reactHooks,
  },
  rules: {
    'react-hooks/exhaustive-deps': ['error'],
    'react-hooks/rules-of-hooks': ['error'],
  },
};

eslint-plugin-react-hooks は recommended ルールセットを提供していないため、全てのルールを明示的に指定します。

jsx-a11y コード例
rules/jsx-a11y.js
import jsxA11y from 'eslint-plugin-jsx-a11y';

export default {
  plugins: {
    'jsx-a11y': jsxA11y,
  },
  rules: {
    ...jsxA11y.configs.recommended.rules,
    // 追加のルール例
    'jsx-a11y/anchor-is-valid': ['error', { components: ['Link'] }],
    // ...
  },
};
typescript コード例
rules/typescript.js
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import parser from '@typescript-eslint/parser';
import bestPractices from './best-practices.js';

export default {
  plugins: {
    '@typescript-eslint': typescriptEslint,
  },
  languageOptions: {
    parser,
    parserOptions: {
      project: true,
    },
  },
  rules: {
    ...typescriptEslint.configs['eslint-recommended'].overrides[0].rules,
    ...typescriptEslint.configs['strict-type-checked'].rules,
    ...typescriptEslint.configs['stylistic-type-checked'].rules,

    // 追加のルール例
    '@typescript-eslint/dot-notation': bestPractices.rules['dot-notation'],
    // ESLint 本体のルールを TypeScript 用に上書きする場合は以下のように記述する
    'no-unused-expressions': 'off',
    '@typescript-eslint/no-unused-expressions': bestPractices.rules['no-unused-expressions'],
    // ...
  }
};

typescript-eslint は ESLint 本体が提供するルールを拡張したものをいくつか含んています。そのようなルールを設定する場合は、設定値の重複を避けるために best-practicesvariables などのルールセットを参照します。

rules: {
  // ESLint 本体のルールを無効化する
  'no-unused-expressions': 'off',
  // TypeScript 用に拡張された同種のルールを設定する
  '@typescript-eslint/no-unused-expressions': bestPractices.rules['no-unused-expressions'],
},

ルールセットをグルーピングした設定を実装する

ルールセットを実装したら、それらをグルーピングした設定を実装します。

essentials

best-practiceserrorses6stylevariablesimportspromise をグルーピングしたものです。

configs/essentials.js
import bestPracticesRuleSet from '../rules/best-practices.js';
import errorsRuleSet from '../rules/errors.js';
import es6RuleSet from '../rules/es6.js';
import importsRuleSet from '../rules/imports.js';
import promiseRuleSet from '../rules/promise.js';
import styleRuleSet from '../rules/style.js';
import variablesRuleSet from '../rules/variables.js';

export default [
  {
    languageOptions: {
      parserOptions: {
        ecmaVersion: 2023,
        sourceType: 'module',
      },
    },
  },
  bestPracticesRuleSet,
  errorsRuleSet,
  es6RuleSet,
  styleRuleSet,
  variablesRuleSet,
  importsRuleSet,
  promiseRuleSet,
];

配列の先頭で languageOptions フィールドを設定し、その後に各ルールセットを追加します。この languageOptions フィールドは ESLint 本体に関する設定のため best-practiceserrorses6stylevariables のいずれかに記述することもできますが、どれか 1 つに記述するのはちぐはぐな印象となり、逆に全てに記述するのは冗長すぎるため、例外的に essentials に記述することにしました。

node

./rules/node.js のみなので、これをインポートしてそのまま返すだけです。

configs/node.js
import nodeRuleSet from '../rules/node.js';

export default [nodeRuleSet];

react

jsx-a11yreactreact-hooks をグルーピングしたものです。これも ./configs/node と同様に 3 つのルールセットをインポートしてそのまま返すだけです。

configs/react.js
// @ts-check
import jsxA11yRuleSet from '../rules/jsx-a11y.js';
import reactHooksRuleSet from '../rules/react-hooks.js';
import reactRuleSet from '../rules/react.js';

export default [
  reactRuleSet,
  reactHooksRuleSet,
  jsxA11yRuleSet,
];

typescript

./rules/typescript.js のみですが、このルールセットは TypeScript ファイルのみを対象とするため、files フィールドを設定して TypeScript 関連ファイル以外に適用されないようにします。

configs/typescript.js
import typescriptRuleSet from '../rules/typescript.js';

export default [
  {
    files: ['**/*.@(ts|tsx|cts|mts)'],
    ...typescriptRuleSet,
  },
];

グルーピングした設定を単一のモジュールファイルに集約する

#インターフェイスを設計する でも述べた通り、グルーピングした設定を単一のモジュールファイルに集約します。

configs/index.js
import essentials from './essentials.js';
import node from './node.js';
import react from './react.js';
import typescript from './typescript.js';

export { essentials, node, react, typescript };

これで利用者は configs/index.js をインポートし、そこから必要な設定を選択して利用できるようになります。

eslint.config.js

import { essentials, node, react, typescript } from './configs';

export default [
  ...essentials,
  ...node,
  ...react,
  ...typescript,
];

各設定は配列型であるため、利用する際は Spread Operator を用いて展開します。本来なら nodetypescript はそれぞれ 1 つのルールセットしか持たないので配列型である必要はありませんが、利用者が統一的なインターフェイスで設定を参照できるようにするため、 essentialsreact に合わせて配列型としています。

Node パッケージ化する

実装した ESLint Config を複数のプロジェクトで利用するならば、Node パッケージ化を検討するのが望ましいでしょう。パッケージは npmjs に公開してもよいですし、GitHub パッケージとして特定の Organization に所属するリポジトリーに閉じて公開するのもよいでしょう。スコープが Monorepo であれば、その Monorepo のサブパッケージとして配置し、他のサブパッケージから参照することも可能です。

パッケージを定義する

package.json を以下のように定義します。

package.json
{
  "name": "@example/eslint-config",
  "version": "1.0.0",
  "type": "module",  // 1.
  "main": "./configs/index.js",  // 2.
  "files": [
    "configs",
    "rules"
  ],
  "devDependencies": {
    "eslint": "8.57.0",
    "typescript": "5.4.5"
  },
  "peerDependencies": {  // 3.
    "eslint": "^8.56.0",
    "typescript": "^4.7.5 || ^5.0.0"
  },
  "peerDependenciesMeta": {  // 4.
    "typescript": {
      "optional": true
    }
  }
}

以下、各フィールドの説明です。

1. type

Flat Config は ES Modules に準拠しているため、type フィールドに "module" を指定します。

2. main

このパッケージのエントリーポイントとなるファイルを指定します。 configs/index.js がインターフェイスとなるため、これを指定します。代わりに exports フィールドを指定することも可能です。

{
  "type": "module",
- "main": "./configs/index.js",
+ "exports": {
+   ".": {
+     "import": "./configs/index.js"
+   }
+ }
  "files": [
    "configs",
    "rules"
  ],
}

3. peerDependencies

このパッケージと併せてインストールが必要な Node パッケージを指定します。この ESLint Config は eslinttypescript に依存しているため、これらを指定します。それぞれのバージョン番号は、この ESLint Config の動作を保証するものを指定します。

4. peerDependenciesMeta

essentialsnodereact しか利用しないプロジェクトであれば TypeScript は不要のため、 typescript パッケージをオプショナルにするために peerDependenciesMeta フィールドを指定します。

ESLint プラグインの扱いについて

本稿で紹介した ESLint Config は eslint-plugin-importeslint-plugin-promiseeslint-plugin-node@typescript-eslint/eslint-plugineslint-plugin-jsx-a11yeslint-plugin-react-hookseslint-plugin-react に依存しています。よってこの ESLint Config を使用するには eslint 本体に加えてこれらのプラグインもインストールする必要があります。どちらが適切でしょうか。

eslint-config-airbnb の場合

利用者側でこれらのプラグインを明示的にインストールさせる設計となっており、全て peerDependencies として指定されています。

https://github.com/airbnb/javascript/blob/master/packages/eslint-config-airbnb/package.json#L87-L93

eslint-config-react-app の場合

一方で eslint-config-react-appdependencies として指定されているため、eslint-config-react-app をインストールする際にこれらのプラグインも併せてインストールされる設計となっています。

https://github.com/facebook/create-react-app/blob/main/packages/eslint-config-react-app/package.json#L22-L37

ようするにこれらプラグインの管理を ESLint Config と利用者のどちらに任せるかという違いです。どちらが適切かは双方のメンテナンスコストを考慮して判断するのがよいでしょう。

参考文献

Discussion