💬

ESLint カスタムルールの作り方

2025/01/13に公開

はじめに

最近自作で ESLint プラグインを作っていて、それを OSS として公開しています。
この記事では、そこで得た知見などをいくつか紹介します。

https://eslint-cdk-plugin.dev/

ESLint とは?

ESLint とは、Javascript / Typescript などのコードを解析し、単純な構文エラーやコーディング規則に違反するコードを検出するツールです。

https://eslint.org

ESLint を使用することで、リポジトリ内のコードの書き方をメンバー間で統一させることができます
(例: if文は必ず{}で囲うようにする(BlockStatement にする))

また、CI などで ESLint のコマンドを実行すれば、レビュー前に不適切なコードを発見でき、レビューコストの削減につながります
さらに、ESLint の拡張機能をエディタ(VSCode など)に導入することで、Lint エラーをリアルタイムで検知することが可能になります

ESLint カスタムルールとは?

ESLint ではルール作成のためのモジュール(型情報)が提供されるため、それに沿ってルールを記述することでカスタムルールを作成することができます。
(ESLint が定義するインターフェースに沿ってルールを書けば動くので、インターフェースさえ守っていれば、ルール作成自体にeslintモジュールは実質不要です)

https://eslint.org/docs/latest/extend/custom-rules

カスタムルールが必要になる場面とは?

以下に、カスタムルールが必要になる場面をいくつか示します。

  • 必要になるルールが ESLint が提供している既存のルールにない
  • 必要になるルールを提供しているプラグインがない
  • 必要になるルールを提供しているプラグインはあるが、メンテされていないなどの理由であまり使いたくない / 使えない
  • ...etc
カスタムルールの具体例
  • private ディレクトリ内のモジュールの呼び出しを制限するルール
    (別階層の private ディレクトリ内のモジュールを import した場合はエラーを出す)
/**
 * 以下のようなディレクトリ構成を想定
 * src
 * ├── sampleA
 * │   └── a.ts
 * └── sampleB
 *     ├── private
 *     │   └── c.ts
 *     ├── a.ts
 *     └── b.ts
 */

// src/sampleA/a.ts

// ✅
import { sample } from "../sampleB/b.ts";

// ❌
import { sample } from "../sampleB/private/c.ts";


このような内容はドキュメントに記載してあっても見落とす / 忘れる場合があり、さらにレビュー時に気付くのも困難だと思うので、linter で規制できていると便利

ESLint のカスタムルールの作り方

初めに、JavaScript コードに対応する ESLint ルールの作り方を紹介します。
(TypeScript への対応は後ほど紹介します)

  • 紹介する内容

    • ❌「どこに何を書くか」といった基本的な情報(インターフェースの解説)
      公式ドキュメントを参照
    • ⭕️「どのようにルールを形成していくか」という内容
  • この章では・・・

    • とりあえず ESLint のルールを作ったことない人向けに、「こういう感じで作るんだ」という流れを掴んでもらう
    • 例として、「if 文は必ず Block Statement で書く」というルールを作る

ざっくりとした仕組み

ESLint では、カスタムルールを利用することで、ESLint にフックを追加できます
(これは ESLint が AST を走査する際に呼び出されます)

つまり AST に沿ってルールを作っていくイメージです

Returns an object with methods that ESLint calls to “visit” nodes while traversing the abstract syntax tree (AST as defined by ESTree) of JavaScript code:

引用: https://eslint.org/docs/latest/extend/custom-rules

ルール作成の流れ

1. テストを書く

テストケースは一旦 正常系 / 異常系 で 1 つずつ書く方が良いです(理由は後述

https://github.com/ren-yamanashi/eslint-custom-plugin-sample/blob/main/lib/tests/require-if-block.test.ts

2. テストケースに書いたコードの AST の内容を見る

AST とは?

AST(Abstract Syntax Tree)はコードをパースした抽象構文木のことです。
JavaScript の場合は JavaScript オブジェクト(JSON)として表現されます

⬇️  参考
https://efcl.info/2016/03/06/ast-first-step/

  • AST の内容を見るには、explorer を使うのが手っ取り早いです(リンクは以下)

※ブラウザ使いたくない場合はリポジトリにespreeをインストールしてログ出力 / ファイル出力することもできます

https://github.com/eslint/js/tree/main/packages/espree

3. AST を見ながら、どのパターンを違反対象にしたいか確認し、コードを実装する

コード実装のポイントは以下の通りです

  1. テストを実行しながら進めていく
  2. 実装を進めていく中で、少し型情報が複雑だったり、any 型の箇所があるので、その部分はconsole.logを仕込み、テストを実行し、それにより出力されるログを確認しながら進めていく
    (この時、一度にテストをたくさん書いてしまうと、対象のログが何のテストのログかが不明になるので、まずは一つずつテストを書いておくのが良いです)

https://github.com/ren-yamanashi/eslint-custom-plugin-sample/blob/main/lib/require-if-block.mts

typescript-eslint を使用する

ESLint のみを使用してカスタムルールを作成した場合、そのルールは TypeScript に対応されていません
(espreeは TypeScript の parser ではなく JavaScript の parser のため、TypeScript コードは parse できない為)

そのため、TypeScript コードを対象とする ESLint のカスタムルールを作成する場合は、typescript-eslintを使用する必要があります

https://typescript-eslint.io/developers/custom-rules/

ざっくりとした仕組み

typescript-eslintでは、@typescript-eslint/typescript-estreeで TypeScript の AST を生成し、@typescript-eslint/parserで ESLint が TypeScript のソースコードを lint できるようにしています

A parser that produces an ESTree-compatible AST for TypeScript code.

引用: https://github.com/typescript-eslint/typescript-eslint/tree/main/packages/typescript-estree

An ESLint parser which leverages TypeScript ESTree to allow for ESLint to lint TypeScript source code.

引用: https://github.com/typescript-eslint/typescript-eslint/tree/main/packages/parser

ルールの書き方

ルールの書き方は ESLint とほぼ同じです。

今回は例として、interfaceのプロパティには必ずreadonlyの付与を強制するルールを作ります
また、このルールでは自動でコードが修正されるようにします(自動でreadonlyがつくようにする)

ルール作成の流れ

1. テストを書く

https://github.com/ren-yamanashi/eslint-custom-plugin-sample/blob/main/lib/tests/no-mutable-interface-property.test.ts

2. テストケースに書いたコードの AST の内容を見る

3. AST を見ながら、どのパターンを違反対象にしたいか確認し、コードを実装する

https://github.com/ren-yamanashi/eslint-custom-plugin-sample/blob/main/lib/no-mutable-interface-property.mts

ここまで、eslinttypescript-eslint を使用した基本的なカスタムルールの作成方法を紹介しました
以降では、よりコアなユースケースに対応したカスタムルールの作成方法を紹介します

typescript-eslint を使用した型の判別方法

typescript-eslintを使用しても、以下のコードのAppクラスが「BaseClassを継承しているか」という情報は AST からは(直接は)読み取れません

// sample.ts
import { BaseClass } from "./base.ts";
export class Sample extends BaseClass {}

// index.ts
export class App extends Sample {}

この章では、このように型情報を判別したい場合にどうしたら良いか?という内容を紹介します

ユースケース

以下に、「型情報を判別したい」具体的なユースケースを取り上げます

特定のクラスを必ず継承するようにする

自前のエラークラスを定義する際は、必ずErrorクラスを継承するようにする
(多重継承の場合も考慮できるようにしたい)

// BaseError.ts
export class BaseError extends Error {}

// InternalServerError.ts

// ✅
export class InternalServerError extends Error {}
export class InternalServerError extends BaseError {}

// ❌
export class InternalServerError {}

ざっくり仕組み

typescript には、getTypeAtLocationという関数があります

https://github.com/microsoft/TypeScript/blob/v5.7.3/src/compiler/types.ts#L5154

これは、Nodeを受け取るとTypeを返す関数で、Typeは以下のような情報を持ちます
このType(interface) のgetBaseType関数を使用することで親の型情報を取得できます

/** @link https://github.com/microsoft/TypeScript/blob/v5.7.3/src/services/types.ts#L109-L137 */
interface Type {
  getFlags(): TypeFlags;
  getSymbol(): Symbol | undefined;
  getProperties(): Symbol[];
  getProperty(propertyName: string): Symbol | undefined;
  getApparentProperties(): Symbol[];
  getCallSignatures(): readonly Signature[];
  getConstructSignatures(): readonly Signature[];
  getStringIndexType(): Type | undefined;
  getNumberIndexType(): Type | undefined;
  getBaseTypes(): BaseType[] | undefined;
  getNonNullableType(): Type;
  getConstraint(): Type | undefined;
  getDefault(): Type | undefined;
  isUnion(): this is UnionType;
  isIntersection(): this is IntersectionType;
  isUnionOrIntersection(): this is UnionOrIntersectionType;
  isLiteral(): this is LiteralType;
  isStringLiteral(): this is StringLiteralType;
  isNumberLiteral(): this is NumberLiteralType;
  isTypeParameter(): this is TypeParameter;
  isClassOrInterface(): this is InterfaceType;
  isClass(): this is InterfaceType;
  isIndexType(): this is IndexType;
}

/**  @link https://github.com/microsoft/TypeScript/blob/v5.7.3/src/compiler/types.ts#L6368-L6384 */
export interface Type {
    flags: TypeFlags;                // Flags
    /** @internal */ id: TypeId;      // Unique ID
    /** @internal */ checker: TypeChecker;
    symbol: Symbol;                  // Symbol associated with type (if any)
    pattern?: DestructuringPattern;  // Destructuring pattern represented by type (if any)
    aliasSymbol?: Symbol;            // Alias associated with type
    aliasTypeArguments?: readonly Type[]; // Alias type arguments (if any)
    /** @internal */
    permissiveInstantiation?: Type;  // Instantiation with type parameters mapped to wildcard type
    /** @internal */
    restrictiveInstantiation?: Type; // Instantiation with type parameters mapped to unconstrained form
    /** @internal */
    immediateBaseConstraint?: Type;  // Immediate base constraint cache
    /** @internal */
    widened?: Type; // Cached widened form of the type
}

つまり、このgetTypeAtLocation関数を呼び出せれば、自身の型情報と、親の型情報を扱えるようになる。というわけです。

また、このgetTypeAtLocationは、以下のように記述することで、typescript-eslintを通じて呼び出すことができます

const parserServices = ESLintUtils.getParserServices(context); // ここまでtypescript-eslint
const typeChecker = parserServices.program.getTypeChecker(); // ここからtypescript
const type = typeChecker.getTypeAtLocation(node);
getTypeAtLocation 関数の実装箇所

getTypeAtLocationでは、NodeTypeを取得する変換するgetTypeOfNode関数を呼び出している

getTypeAtLocation 関数

https://github.dev/microsoft/TypeScript/blob/v5.7.3/src/compiler/checker.ts#L1693-1696

getTypeOfNode関数

https://github.dev/microsoft/TypeScript/blob/v5.7.3/src/compiler/checker.ts#L49259-49338

実際のコードは以下のようになります

const parserServices = ESLintUtils.getParserServices(context);
const typeChecker = parserServices.program.getTypeChecker();
const tsnode = parserServices.esTreeNodeToTSNodeMap.get(node); // TSESTree -> TSNode
const type = typeChecker.getTypeAtLocation(tsnode);

// typescript-eslintには⬆️のような書き方ををラップしてる関数が用意されている
// ⬇️実際に書くべきコード
const parserServices = ESLintUtils.getParserServices(context);
const type = parserServices.getTypeAtLocation(node);

まとめ

eslint で自作ルールを作る方法を紹介しました。
また、コアなユースケースとして、AST と 型情報の扱い方を紹介しました。

個人的には、「このミスまた起こりそう」と思ったら、一回 lint でルール化できないか / 既存のルールないか検討してみるのが良いと思っています。

Discussion