ESLint カスタムルールの作り方
はじめに
最近自作で ESLint プラグインを作っていて、それを OSS として公開しています。
この記事では、そこで得た知見などをいくつか紹介します。
ESLint とは?
ESLint とは、Javascript / Typescript などのコードを解析し、単純な構文エラーやコーディング規則に違反するコードを検出するツールです。
ESLint を使用することで、リポジトリ内のコードの書き方をメンバー間で統一させることができます
(例: if
文は必ず{}
で囲うようにする(BlockStatement にする))
また、CI などで ESLint のコマンドを実行すれば、レビュー前に不適切なコードを発見でき、レビューコストの削減につながります
さらに、ESLint の拡張機能をエディタ(VSCode など)に導入することで、Lint エラーをリアルタイムで検知することが可能になります
ESLint カスタムルールとは?
ESLint ではルール作成のためのモジュール(型情報)が提供されるため、それに沿ってルールを記述することでカスタムルールを作成することができます。
(ESLint が定義するインターフェースに沿ってルールを書けば動くので、インターフェースさえ守っていれば、ルール作成自体にeslint
モジュールは実質不要です)
カスタムルールが必要になる場面とは?
以下に、カスタムルールが必要になる場面をいくつか示します。
- 必要になるルールが 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 つずつ書く方が良いです(理由は後述
2. テストケースに書いたコードの AST の内容を見る
AST とは?
AST(Abstract Syntax Tree)はコードをパースした抽象構文木のことです。
JavaScript の場合は JavaScript オブジェクト(JSON)として表現されます
⬇️ 参考
- AST の内容を見るには、explorer を使うのが手っ取り早いです(リンクは以下)
- https://ast.sxzz.dev/
- https://astexplorer.net/
- ESLint は、parser としてespreeを使用しているので、parser は
espree
に設定しておくとより正確な AST を確認できます
※ブラウザ使いたくない場合はリポジトリにespree
をインストールしてログ出力 / ファイル出力することもできます
3. AST を見ながら、どのパターンを違反対象にしたいか確認し、コードを実装する
コード実装のポイントは以下の通りです
- テストを実行しながら進めていく
- 実装を進めていく中で、少し型情報が複雑だったり、any 型の箇所があるので、その部分は
console.log
を仕込み、テストを実行し、それにより出力されるログを確認しながら進めていく
(この時、一度にテストをたくさん書いてしまうと、対象のログが何のテストのログかが不明になるので、まずは一つずつテストを書いておくのが良いです)
typescript-eslint を使用する
ESLint のみを使用してカスタムルールを作成した場合、そのルールは TypeScript に対応されていません
(espree
は TypeScript の parser ではなく JavaScript の parser のため、TypeScript コードは parse できない為)
そのため、TypeScript コードを対象とする ESLint のカスタムルールを作成する場合は、typescript-eslint
を使用する必要があります
ざっくりとした仕組み
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. テストを書く
2. テストケースに書いたコードの AST の内容を見る
- AST の内容を見るには、explorer を使うのが手っ取り早いです(リンクは以下)
- https://ast.sxzz.dev/
- https://astexplorer.net/
-
typescript-eslint
は、parser として@typescript-eslint/parserを使用しているので、parser は@typescript-eslint/parser
に設定しておくとより正確にASTを確認できます
3. AST を見ながら、どのパターンを違反対象にしたいか確認し、コードを実装する
ここまで、eslint
と typescript-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
という関数があります
これは、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
では、Node
のType
を取得する変換するgetTypeOfNode
関数を呼び出している
getTypeAtLocation
関数
getTypeOfNode
関数
実際のコードは以下のようになります
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