🙅

ESLintのカスタムルールをTypeScriptで書いてみた

2023/08/25に公開

はじめに

paiza株式会社の小野です。
直近ESLintやRuboCopなどのlinter設定に触れる機会が増えてきたのですが、
linterの設定を整えていくと気持ちよくコードが書けますし、ルール調査を通して言語そのものについて理解度も向上するのでlinter触るのいいなあと思っています。

せっかくなのでESLintについてさらに理解しておきたい、TypeScriptを書く機会もかなり増えてきたので今後役に立つかもしれない、ということでESLintのカスタムルールをTypeScriptで書く方法を調べてみました。

ゴール

  • 簡単なカスタムルールを作ってみて、ローカルプロジェクト内でESLintに指摘させる
  • TypeScriptのコードをチェックするカスタムルールをTypeScriptで記述する[1]

カスタムルールを作る

今回は例として、TypeScriptのユーティリティ型であるOmitを使っている箇所を指摘するルールを作ってみましょう。[2][3]

例えば以下のコードではPersonName型を作るためにOmitを使っていますが、これを指摘させることにします。

type Person = { name: string; age: number }
type PersonAge = Pick<Person, "age">;  // ok
type PersonName = Omit<Person, "age">; // error
const Omit = () => { console.log('Omitをomitしたい'); } // ok

準備

ESLintが入ったTypeScript環境については、ESLint公式ドキュメントやサバイバルTypeScriptを参考にしつつ構築しました。
後者は導入方法や使い方だけでなく背景知識も丁寧に解説されており、linterやESLintを使ってみたい・理解を深めたいという方は一度目を通してみることをおすすめします。

https://eslint.org/docs/latest/use/getting-started
https://typescriptbook.jp/tutorials/eslint

今回はeslint-no-omit-utility-typeというディレクトリを作成し、必要なパッケージをインストールします。[4]

mkdir eslint-no-omit-utility-type
cd eslint-no-omit-utility-type

yarn init -y
yarn add -D 'eslint@^8' 'typescript@^4.6' '@types/node@^16' '@typescript-eslint/eslint-plugin@^6' '@typescript-eslint/parser@^6' '@typescript-eslint/utils@^6'
yarn tsc --init
touch .eslintrc.js
touch Person.ts
mkdir src

tsconfig.json.eslintrc.js はそれぞれ以下を記述しておきます。

tsconfig.json
{
  "compilerOptions": {
    "target": "es2021",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
  },
  "include": ["src", "*.ts"]
}
.eslintrc.js
module.exports = {
  "env": {
    "browser": true,
    "es2021": true
  },
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": 13,
    "sourceType": "module"
  },
  "plugins": [
    "@typescript-eslint"
  ],
};

Person.tsには先ほどの例のコードを記述しておきます。

Person.ts
type Person = { name: string; age: number }
type PersonAge = Pick<Person, "age">;  // ok
type PersonName = Omit<Person, "age">; // error
const Omit = () => { console.log('Omitをomitしたい'); } // ok

試しにyarn eslint Person.tsを実行して、指摘が発生しないことを確認しておきましょう。

yarn eslint Person.ts
yarn run v1.22.19
$ /Users/onoshi/eslint-no-omit-utility-type/node_modules/.bin/eslint Person.ts
✨  Done in 0.63s.

ルールを記述する

srcディレクトリ配下にno-omit-utility-type.tsを作成し、ルールを記述していきます。

touch src/no-omit-utility-type.ts

@typescript-eslint/utilsが提供しているESLintUtils.RuleCreator()[5]を利用して記述していきます。以下が大枠です。

src/no-omit-utility-type.ts
import { ESLintUtils } from '@typescript-eslint/utils';

const createRule = ESLintUtils.RuleCreator(
  name => `https://example.com/rule/${name}` // ルールのドキュメントURL
);

export const rule = createRule({
  create(context) {
    return {
      // ここにルールの実装を書く
    };
  },
  // ここにルールのオプションを記述する e.g. name, meta, defaultOptionsなど
});

これを踏まえて、まずはルールの実装部分を記述していきます。方針としては以下を実装すればOKですので、順に見ていきます。

  1. 特定のコード表現(今回はOmit)が存在するかどうかをチェックする
  2. context.report()メソッドを呼び出す

特定のコード表現が存在するかどうかをチェックする

ここで登場するのが、AST(Abstract Syntax Tree, 抽象構文木)です。ASTについての詳細な解説はこの記事では割愛しますが、ASTはコードを構成する要素を木構造で表現したもので、ESLintのようなlinterはASTを利用してコードを解析しています。
例えばPerson.tsのASTをTypeScript AST Viewerで確認してみると、このように構成されていることがわかります。

Omitをクリックすると、どのノードが対応しているのかが分かります

typescript-eslintには各ノードに対応するTSInterfaceDeclarationTSTypeAnnotationのようなTSで始まる名前のメソッドがあり、ノードについての処理を書くことができます。
上の画像を見ると、OmitTypeReference配下のIdentifierとして表現されているようですので、TSTypeReferenceについて条件を記述してあげれば良さそうです。

入力補完やコンパイラの支援を受けることで、各ノードやそれに関連する要素についての調査コストを大きく削減することができます。TypeScriptでカスタムルールを書くメリットですね。

src/no-omit-utility-type.tsに追記したものが以下です。

src/no-omit-utility-type.ts
import { ESLintUtils } from '@typescript-eslint/utils';

const createRule = ESLintUtils.RuleCreator(
  name => `https://example.com/rule/${name}` // ルールのドキュメントURL
);

export const rule = createRule({
  create(context) {
    return {
      // ここにルールの実装を書く
      TSTypeReference(node) {
        if (node.typeName.type === 'Identifier' && node.typeName.name === 'Omit') {
          // context.report()メソッドを呼び出す
        }
      }
    };
  },
  // ここにルールのオプションを記述する e.g. name, meta, defaultOptionsなど
});

context.report()メソッドを呼び出す

指摘させたいコード表現についての条件が記述できたら、context.report()メソッドを呼び出して指摘させます。
型曰く最低限nodemessageIdをプロパティに持つオブジェクトを渡してあげれば良さそうなので、追記します。

src/no-omit-utility-type.ts
import { ESLintUtils } from '@typescript-eslint/utils';

const createRule = ESLintUtils.RuleCreator(
  name => `https://example.com/rule/${name}` // ルールのドキュメントURL
);

export const rule = createRule({
  create(context) {
    return {
      TSTypeReference(node) {
        if (node.typeName.type === 'Identifier' && node.typeName.name === 'Omit') {
          // context.report()メソッドを呼び出す
          context.report({
            node,
            messageId: 'no-omit-utility-type',
          });
        }
      }
    };
  },
  // ここにルールのオプションを記述する e.g. name, meta, defaultOptionsなど
});

ルールのオプションを記述する

引き続き型の支援に感謝しつつルールのオプションを記述してあげれば完成です。

src/no-omit-utility-type.ts
import { ESLintUtils } from '@typescript-eslint/utils';

const createRule = ESLintUtils.RuleCreator(
  name => `https://example.com/rule/${name}` // ルールのドキュメントURL
);

export const rule = createRule({
  create(context) {
    return {
      TSTypeReference(node) {
        if (node.typeName.type === 'Identifier' && node.typeName.name === 'Omit') {
          context.report({
            node,
            messageId: 'no-omit-utility-type',
          });
        }
      }
    };
  },
  meta: {
    type: 'problem',
    docs: {
      description: 'disallow the use of the `Omit` utility type',
    },
    messages: {
      'no-omit-utility-type': 'The `Omit` utility type is forbidden',
    },
    schema: [],
  },
  name: 'no-omit-utility-type',
  defaultOptions: [],
});

ルールの記述や各プロパティの内容については、必要に応じてESLintおよびtypescript-eslintのドキュメントを参照いただければと思います。
https://eslint.org/docs/latest/extend/custom-rules#reporting-problems
https://typescript-eslint.io/developers/custom-rules

カスタムルールを使う

作ったカスタムルールを使って、意図通りの指摘が発生することを確認してみましょう。今回はeslint-plugin-local-rulesを利用してローカルプロジェクト内で試してみます。
eslint-plugin-local-rulesをインストールしておきましょう。eslint-local-rules.jsも作成しておきます。

yarn add -D eslint-plugin-local-rules
touch eslint-local-rules.js

eslint-local-rules.jsには以下を記述します。

eslint-local-rules.js
module.exports = {
  "no-omit-utility-type": require("./src/no-omit-utility-type").rule,
};

.eslintrc.jspluginsrulesに以下を追記します。この記述により、eslint-plugin-local-rulesを通してno-omit-utility-typeをESLintに認識させることができます。

.eslintrc.js
module.exports = {
  // ...
  "plugins": [
    "@typescript-eslint",
    "local-rules"
  ],
  "rules": {
    "local-rules/no-omit-utility-type": "error"
  }
};

src/no-omit-utility-type.tsをコンパイルして、yarn eslint Person.tsを実行してみます。

yarn tsc # src/no-omit-utility-type.jsが生成される
yarn run v1.22.19
$ /Users/onoshi/eslint-no-omit-utility-type/node_modules/.bin/tsc
✨  Done in 0.65s.

yarn eslint Person.ts
yarn run v1.22.19
$ /Users/onoshi/eslint-no-omit-utility-type/node_modules/.bin/eslint Person.ts

/Users/onoshi/eslint-no-omit-utility-type/Person.ts
  3:19  error  The `Omit` utility type is forbidden  local-rules/no-omit-utility-type

✖ 1 problem (1 error, 0 warnings)

error Command failed with exit code 1.

無事ESLintに指摘してもらうことができました!

VS Code上でも指摘が入っています

まとめ

この記事ではESLintのカスタムルールをTypeScriptで記述し、eslint-plugin-local-rulesを利用してローカルプロジェクト内でESLintの指摘を受けられることを確認しました。
周辺知識に関しては各パッケージの公式ドキュメントや文中および後述の参考文献に委ねる方針として、この記事内では多くを割愛させていただきました。
ESLintのカスタムルールを書いてみたいという方はぜひそちらにも目を通していただけたらと思います。

補記

TypeScriptの特定の型を禁止したい場合は、@typescript-eslint/ban-typesを利用できます。

.eslintrc.js
module.exports = {
  // ...
  "rules": {
    // "local-rules/no-omit-utility-type": "error",
    "@typescript-eslint/ban-types": [
      "error",
      {
        "types": {
          "Omit": "",
        },
        "extendDefaults": true
      }
    ]
  }
};
yarn eslint Person.ts
yarn run v1.22.19
$ /Users/onoshi/eslint-no-omit-utility-type/node_modules/.bin/eslint Person.ts

/Users/onoshi/eslint-no-omit-utility-type/Person.ts
  3:19  error  Don't use `Omit` as a type.   @typescript-eslint/ban-types

✖ 1 problem (1 error, 0 warnings)

error Command failed with exit code 1.

参考文献

文中に掲載していないものを中心に、参考にさせていただいた記事を以下にまとめておきます。
https://blog.sa2taka.com/post/custom-eslint-rule-with-typescript/
https://zenn.dev/meijin/scraps/04340d20070f71
https://qiita.com/swallowtail62/items/0c15f02ef47c218cfb65

脚注
  1. 素のESLintではTypeScriptのコードをチェックすることはできません。また、カスタムルールはJavaScriptで記述します。https://eslint.org/docs/latest/extend/custom-rule-tutorial ↩︎

  2. Omitはオブジェクトの型から特定のプロパティを除外した型を作ることができますが、Pickと異なり存在しないプロパティキーを指定してもTypeScriptコンパイラは指摘しない仕様となっています。これは歴史的経緯によるものとのことです。 ↩︎

  3. Omitの利用を避けましょう」という意図ではなく、あくまで例として。Omitの利用シーンについてはこちらの記事が参考になりました。 ↩︎

  4. typescript-eslintについて、サバイバルTypeScriptではバージョン5系を利用していますが、今回は執筆時点で最新のバージョン6系を利用しました。
    6系ではtsconfig.jsonmodulemoduleResolutionの内容によってはimport時にエラーが発生します。ハマって2時間くらい溶かしました。 ↩︎

  5. 代わりにESLintUtils.RuleCreator.withoutDocs()を利用することでドキュメントなしのカスタムルールを作成することもできますが、非推奨とされているためここでは仮のURLを指定しておくことにします。 ↩︎

paiza

Discussion