wandfuldays

ESLint カスタムルール実装ガイド | esquery の力を活かす!

2025-04-302025-05-08

ESLint カスタムルールの実装方法と具体例をご紹介。 esquery の強力なセレクタ表現でほぼ実装いらず!

こんにちは、 wand です!

今ご覧いただいているこのブログシステムは私が自分で構築したものです。

AWS + Next.js + microCMS で Jamstack ブログシステムを構築しました

最近は技術的な改善を多数入れました:

  • Next 15 / React 19 へのバージョンアップ
  • ViewTransition の導入
  • npm から pnpm への移行
  • etc...

中でも、 Next 15 への移行では App Router 配下のページの params Prop が Promise になるなど、破壊的変更がありました。

Upgrading: Version 15
- type Params = { slug: string }
+ type Params = Promise<{ slug: string }>

このような API 変更に対応する中で、同様のコード修正が複数発生し、今後も古い書き方をしてしまう可能性もあるため、ESLint カスタムルールで自動検出・警告できないかと考えました。
本記事では ESLint カスタムルールの開発ノウハウをご紹介します!

コードは GitHub で公開していますのでご活用ください!

GitHub - wand2016/blog: AWS infra + Next.js microCMS template

カスタムルールの開発環境構築については以下の記事で解説しています!

pnpm workspace で始める ESLint カスタムルール開発入門

ESLint Custom Rules 公式ドキュメント

本記事では、下記ドキュメントの内容をベースとして補足説明やノウハウの解説を行います。

Custom Rules - ESLint - Pluggable JavaScript Linter

ECMAScript AST を AST Explorer で確認する

ESLint カスタムルールは ECMAScript (≒ JavaScript, TypeScript ) の AST (Abstract Syntax Tree, 抽象構文木) に対して Visitor を書くことで実装します。

ECMAScript の AST を確認するには「AST Explorer」を用いると便利です。

AST explorer

言語として JavaScript 、パーサーとして @typescript-eslint/parser を指定すると TypeScript の型情報も取得・表示できます。

ESLint カスタムルールを実装する場合はこの AST を見ながら進めるとたいへん効率的です!

estools/esquery のススメ

ESLint カスタムルールを実装するにあたっては、

  • 「変数定義 ( VariableDeclaration ) で変数名を取り締まる」
  • 「return 文 ( ReturnStatement ) で行数を取り締まる」

といったように、AST 上の特定のノード ( VariableDeclaration, ReturnStatement ) に対してロジックを記述する必要があります。

このように AST 上のノードを特定する方法は、主に以下の2つがあります:

  • ノードの type に対して if 文を用いる
  • ノードを CSS のようなセレクタを用いて指定する

この2つのうち、if 文を用いると手続き的で null チェック等も煩雑になってしまうため、なるべくセレクタを用いて指定すると良いでしょう。

「CSS のようなセレクタ」として、 ESLint カスタムルールの実装では esquery が利用できます:

GitHub - estools/esquery: ECMAScript AST query library.

:has(), :not() のような擬似クラスや [attr="abc"] のような属性セレクタも利用可能であり、とても表現力豊かです。

手続き的な条件分岐に頼る前に、まずは esquery セレクタで表現可能かを検討しましょう。

以下、今回実際に実装したルールをご紹介します。

app-router-params-prop-promise ルール

App Router 配下の Props という型定義について、 params という prop が Promise でなければ違反とします。

✅️ valid

App Router 配下の page component で、 paramsPromise である:

type Props = {
  params: Promise<{
    id: string;
  }>;
}

App Router 配下でないファイルで、 paramsPromise でない:

type Props = {
  params: {
    id: string;
  };
}

❌️ invalid

App Router 配下の page component で、 paramsPromise でない:

type Props = {
  params: {
    id: string;
  };
}

↓ 下記のように自動修正します:

type Props = {
  params: Promise<{
    id: string;
  }>;
}

実装

AST Explorer を用いて下記のような AST を表示します:

この AST を見ながら、下記のような ESLint ルールを実装しました:

/** @type {import("eslint").Rule.RuleModule} */
const rule = {
  meta: {
    type: "problem",
    docs: {
      description: "App Router params prop should be a Promise.",
    },
    fixable: "code",
    schema: [],
  },
  create(context) {
    const filename = context.filename ?? context.getFilename();
    const sourceCode = context.sourceCode ?? context.getSourceCode();

    return {
      /**
       * @param {import("@typescript-eslint/typescript-estree").TSESTree.TSPropertySignature} node
       */
      'Program > TSTypeAliasDeclaration:has(>Identifier[name="Props"]) > TSTypeLiteral > TSPropertySignature:has(>Identifier[name="params"]):not([typeAnnotation.typeAnnotation.type="TSTypeReference"][typeAnnotation.typeAnnotation.typeName.type="Identifier"][typeAnnotation.typeAnnotation.typeName.name="Promise"])'(
        node,
      ) {
        if (!filename.includes("/app/")) return;

        context.report({
          node,
          message:
            "App Router 配下のページの params prop は Promise である必要があります。",
          fix(fixer) {
            const typeAnnotation = sourceCode.getText(
              node.typeAnnotation.typeAnnotation,
            );
            const newTypeAnnotation = `Promise<${typeAnnotation}>`;
            return fixer.replaceText(
              node.typeAnnotation.typeAnnotation,
              newTypeAnnotation,
            );
          },
        });
      },
    };
  },
};

module.exports = rule;

このカスタムルールのロジックの大半は下記の esquery セレクタに集約されます:

Program > 
TSTypeAliasDeclaration:has(
    >Identifier[name="Props"]
) >
TSTypeLiteral >
TSPropertySignature:has(
    >Identifier[name="params"]
):not(
    [typeAnnotation.typeAnnotation.type="TSTypeReference"][typeAnnotation.typeAnnotation.typeName.type="Identifier"][typeAnnotation.typeAnnotation.typeName.name="Promise"]
)

(意味が変わらない範囲で改行して見やすくしてあります)

  1. 「ファイルのトップレベルの」
  2. TSTypeAliasDeclaration で、Props という名前のものの子の」
  3. TSTypeLiteral の子の」
  4. TSPropertySignature で、 params という名前のもので」
  5. 「型 ( typeAnnotation.typeAnnotation ) が Promise でないもの」

を見つけて違反を報告します。

(それに加えて、ファイルパスに /app/ が含まれているものを App Router 配下とみなします)

動作確認

Promise でない params prop をちゃんと検出できてます!
以下のように自動修正も確認できました。

自動修正後

no-redundant-template-literal ルール

`${foo}`のようなテンプレートリテラルは冗長なので違反とします。

✅️ valid

`foo`
`foo${bar}baz`
`${foo}${bar}`
`${foo} `
` ${foo}`
` ${foo} `

❌️ Invalid

`${foo}`

↓ 下記のように自動修正します:

String(foo)

foo がもともと文字列の場合は String() は不要ですが、それは手で修正します。

実装

AST Explorer を用いて下記のような AST を表示します:

この AST を見ながら、下記のような ESLint ルールを実装しました:

/** @type {import("eslint").Rule.RuleModule} */
const rule = {
  meta: {
    type: "problem",
    docs: {
      description:
        "Template literal with single expression should be replaced with String casting.",
    },
    fixable: "code",
    schema: [],
  },
  create(context) {
    const sourceCode = context.sourceCode ?? context.getSourceCode();

    return {
      /**
       * @param {import("estree").TemplateLiteral} node
       */
      'TemplateLiteral[expressions.length=1][quasis.length=2][quasis.0.value.raw=""][quasis.1.value.raw=""]'(
        node,
      ) {
        context.report({
          node,
          message:
            "単一の式を含むテンプレートリテラルは冗長です。String でキャストしてください。",
          fix(fixer) {
            const expression = node.expressions[0];
            const text = sourceCode.getText(expression);
            return fixer.replaceText(node, `String(${text})`);
          },
        });
      },
    };
  },
};

module.exports = rule;

このルールに至っては、なんとロジックのすべてが下記の esquery セレクタで実現できてしまいました!

TemplateLiteral[expressions.length=1][quasis.length=2][quasis.0.value.raw=""][quasis.1.value.raw=""]

「テンプレートリテラルで、式 ( expressions ) をちょうど 1 つ内包しており、式以外の文字列部分 ( quasis ) がちょうど 2 つで、式の前後の文字列部分 ( quasis.0.value.raw, quasis.1.value.raw) が空 ( "" ) であるもの」

を見つけて違反を報告します。

このように、 esquery の表現力は大変強力で、手続き的な実装が一切不要になることも!

動作確認

今まで目が滑っていて見過ごしていてた冗長な Template Literal を検出できました!
以下のように自動修正も動作確認できました。

自動修正後

(この例では String() によるキャストは冗長なので、さらに process.env.USE_DRAFT ?? '' のように手で修正しました)

まとめ

本記事では、pnpm の workspace を用いて monorepo を構成し、ESLint のカスタムルールを開発・適用する手順をご紹介しました。

ESLint のカスタムルールは、意外と手軽に作ることができ、一度導入すれば、継続的に開発生産性を底上げできます。

カスタムルールを社内で共有したり、OSS として公開することもできます!

「毎回同じような修正をしている…」
「レビューで似た指摘ばかりしている…」

そんなときは、静的解析による自動化 が有効です。
少しでも思い当たることがあれば、カスタムルール開発に挑戦してみてはいかがでしょうか。
日々の開発がもっと生産的になりますよ!


wand

「wand」は魔法の杖を意味します。魔法のようにさまざまなものを自分の手で生み出せるようになりたい、そんな思いを込めました。 ハンドメイド、家庭菜園、DIY、プログラミング等、「つくる」をテーマに色々なことをしていきたいと思っています。 Amazonのアソシエイトとして、wand は適格販売により収入を得ています。 GitHub: https://github.com/wand2016