🧿

同じ名前で分割代入する場合はショートハンドで強制するESLintルールをTypeScriptで作った

に公開
4

こんにちは!CastingONEの大沼です。

始めに

弊社ではESLintルールのobject-shorthandを設定しており、ショートハンドで書けるものはショートハンドで強制されるようにしています。これによってコードがスッキリしたものになるのですが、一部ショートハンドにならないケースがありました。

object-shorthandでautofixするケースとしないケース
const hoge = 'hogehoge';

const obj = {
  hoge: hoge, // これはautofixしてくれる
  fuga: 100,
};

const {
  fuga: fuga, // これはそもそもlintエラーにならない
} = obj;

ルールの内容をよく見るとどうやらオブジェクトを作る際のショートハンドのみ見ているようで、分割代入の方は対象外ということが分かりました。代わりのルールを探してみたのですが、最も近そうなプラグインがeslint-plugin-prefer-destructuringで、ルールの内容を見るとショートハンドに関するものはありませんでした。
無いのであれば作れば良い!ということでカスタムルールを作ってみましたのでその内容についてまとめました。

作ったもの

先に今回作ったものをお見せしますと、以下のgifアニメのようなものになります。なお、エディタ上で赤くなっているのはTypeScriptのエラーでESLintのエラー内容では無いので注意してください。gifアニメのものはStackBlitzで作ったものになっており、StackBlitzではESLintのエラーをエディタ上に表示することはできません。

詳細のコードや動作を確認したい方は以下にStackBlitzで作ったものを埋め込んでおりますのでこちらをご参照ください。ESLintの実行はnpm run lintで、autofixを試す場合はnpm run lint:fixをターミナルで実行していただければ確認できます。

カスタムルールの作成

対象となるコードのAST構造を理解する

まず最初にESLintルールでマッチする条件を設定するために、分割代入している場所がASTだとどういう構造になっているか調べます。
ASTの解析は以下に貼ったAST Explorerを使うと非常に分かりやすいです。

https://astexplorer.net/

このサイトでサンプルコードを書いて、分割代入されているところがどうなっているか見ます。
まず const { hoge, foo: foo } = obj のようなコードですが、これを書いてAST Explorerで見るとスクショのようなASTツリーが表示され、 VariableDeclarator > ObjectPattern > Propertyという構造になっていることが分かります。

Propertyの詳細をショートハンドの時とそうで無い時を見ると、shorthandというプロパティがtrue/falseとなっており、これがfalseの時、かつkey.namevalue.nameが一致しているときにESLintでエラーとして検知すると良さそうです。

分割代入のケースはアロー関数や通常の関数の場合もあり、こちらの構造も確認しました。 Propertyの部分はどこも同じ構造だったので大枠のところのみ載せます。

アロー関数: VariableDeclarator > ArrowFunctionExpression > ObjectPattern > Property

通常の関数: FunctionDeclaration > ObjectPattern > Property

AST構造を元にカスタムルールを作成

エラーとして検知させる部分が分かったので実際にルールを作っていきます。今回はTypeScriptで作りますので、以下のパッケージをインストールします。

npm install -D eslint @types/eslint typescript

まずはESLintのカスタムルールの大枠を書きます。metaにカスタムルールの情報を書きますが、今回はリファクタするものなのでtype: 'suggestion'とし、autofixできるようにするためfixable: 'code'を入れています。createにルールの内容について定義するのですが、ESLintのカスタムルールはビジターパターンとなっており、特定のブロックに処理を差し込むように設定します。今回はVariableDeclaratorFunctionDeclarationにアクセスしたいので以下のように書きます。

カスタムルールの大枠
import type { Rule } from 'eslint';
import type { AssignmentProperty, RestElement } from 'estree';

export const preferDestructuringShorthand: Rule.RuleModule = {
  meta: {
    type: 'suggestion', // リファクタ内容なのでsuggestion
    fixable: 'code', // autofix可能なので設定
  },
  create(context) {
    // チェックしたいASTブロックをオブジェクトの関数で定義
    return {
      VariableDeclarator: (node) => {
        // 変数宣言ブロックの処理
      },
      FunctionDeclaration: (node) => {
        // 関数宣言ブロックの処理
      },
    };
  },
};

VariableDeclaratorFunctionDeclarationの第一引数であるnodeは前セクションで確認したAST構造がそのまま入っているので、それに従ってコードを書いていけば良いです。Property部分はどれも同じものが入っていたのでそこの処理は共通化されるように書きます。

rules/prefer-destructuring-shorthand.ts
import type { Rule } from 'eslint';
import type { AssignmentProperty, RestElement } from 'estree';

export const preferDestructuringShorthand: Rule.RuleModule = {
  meta: {
    type: 'suggestion',
    fixable: 'code',
  },
  create(context) {
    /**
     * Propertyブロックのチェック
     * @param properties - Propertyブロックリスト
     */
    const checkProperties = (
      properties: Array<AssignmentProperty | RestElement>
    ) => {
      // 詳細の実装は後述
    };

    return {
      VariableDeclarator: (node) => {
        if (node.init) {
          // アロー関数のパターン
          if ('params' in node.init) {
            node.init.params.forEach((param) => {
              if (param.type !== 'ObjectPattern') {
                return;
              }
              checkProperties(param.properties);
            });
          }
        }

        // オブジェクトのパターン
        if (node.id.type === 'ObjectPattern') {
          checkProperties(node.id.properties);
        }
      },
      // 通常の関数のパターン
      FunctionDeclaration: (node) => {
        node.params.forEach((param) => {
          if (param.type !== 'ObjectPattern') {
            return;
          }
          checkProperties(param.properties);
        });
      },
    };
  },
};

最後にPropertyブロックの確認を実装します。AST Explorerで確認した内容を元に合致しないケースは早期リターンで除外していき、最終的に残った部分をcontext.reportでESLintに修正が可能な箇所だと通知します。

checkPropertiesの詳細実装
import type { Rule } from 'eslint';
import type { AssignmentProperty, RestElement } from 'estree';

export const preferDestructuringShorthand: Rule.RuleModule = {
  // metaの設定は同じなので省略
  create(context) {
    /**
     * Propertyブロックのチェック
     * @param properties - Propertyブロックリスト
     */
    const checkProperties = (
      properties: Array<AssignmentProperty | RestElement>
    ) => {
      properties.forEach((property) => {
        if (property.type !== 'Property') {
          return;
        }
        if (property.shorthand) {
          return;
        }

        const { key, value } = property;
        if (key.type !== 'Identifier' || value.type !== 'Identifier') {
          return;
        }
        if (key.name !== value.name) {
          return;
        }

        // 修正が可能な箇所と通知
        context.report({
          node: property, // 修正対象のASTブロック
          message:
            '同じ名前で分割代入する場合はショートハンドを使用してください',
          // 修正コード
          fix(fixer) {
            return fixer.replaceText(property, key.name);
          },
        });
      });
    };

    return {
      // returnオブジェクトの内容は同じなので省略
    };
  },
};

カスタムルールを設定

カスタムルールが完成したので、これを実際にESLintルールに設定します。以下の記事のように最近はTypeScriptのままでも実行できるようになったのでその方法で試しました。

https://roboin.io/article/2024/08/13/eslint-now-supports-typescript-based-config-files/

ESLintのセットアップ

まずは基本となるESLintを用意します。基本ルールのインストールと、今回はTypeScriptで実行するのでjitiも合わせてインストールします。

npm install -D @eslint/js typescript-eslint jiti

eslint.config.mtsに以下のようなコードを書きます。

eslint.config.mts
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config({
  files: ['**/*.{ts,tsx}'],
  extends: [eslint.configs.recommended, ...tseslint.configs.recommended],
});

カスタムルールを追加

次にこのコンフィグにカスタムルールを追加します。カスタムルールを追加するにはプラグインにする必要があるので、まずはそれを作ります。rulesプロパティの中にルール名をキーとして先ほど作ったカスタムルールのコードを入れるだけでプラグインはできます。

rules/index.ts
import type { ESLint } from 'eslint';

import { preferDestructuringShorthand } from './prefer-destructuring-shorthand';

const plugin = {
  rules: {
    'prefer-destructuring-shorthand': preferDestructuringShorthand,
  },
} satisfies ESLint.Plugin;

export default plugin;

これをimportしてプラグインとして登録することでカスタムルールを使うことができます。先頭の名前はpluginsのキー名と一致させる必要があるのでそこは注意してください。今回はlocalというキー名でプラグインを登録しているのでルール名がlocal/を先頭につける必要があります。

カスタムルールを追加
 import eslint from '@eslint/js';
 import tseslint from 'typescript-eslint';

+import localPlugin from './rules';

 export default tseslint.config({
   files: ['**/*.{ts,tsx}'],
   extends: [eslint.configs.recommended, ...tseslint.configs.recommended],
+  plugins: {
+    local: localPlugin,
+  },
+  rules: {
+    'local/prefer-destructuring-shorthand': 'error',
+  },
 });

カスタムルールの動作確認

早速ESLintを実行すると以下のようにカスタムルールのエラーが出てくるようになりました🎉

StackBlitz上ではコード上にはエラーメッセージは表示できませんが、弊社の環境でこのルールを入れた時にはしっかりとVSCode上でエラーが出ていました。

カスタムルールのテスト

最後に作成したカスタムルールのテストを書きたいと思います。今回はvitestを使ってテストしたいのでこれをインストールします。

npm install -D vitest

テスト用の設定ファイルは通常のテストと別にできるようにファイル名をvitest.rules.config.tsにして以下のようにrulesディレクトリ配下のテストコードのみ実行されるようにしました。

vitest.rules.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    root: './',
    include: ['rules/**/*.test.ts'],
  },
});

テストコードは以下のようにESLintのRuleTesterを使って、validとinvalidのケースを用意します。validはcodeのみでよく、invalidの場合はエラーの内容とautofixをする場合はfix後のコードを定義します。nameはテスト実行時に出てくる名前なので見やすくしたい場合は任意で設定してください。未指定の場合はcodeがそのまま表示されます。

prefer-destructuring-shorthand.test.ts
import { RuleTester } from 'eslint';

import { preferDestructuringShorthand } from './prefer-destructuring-shorthand';

const ruleTester = new RuleTester();

const errorMessage = '同じ名前で分割代入する場合はショートハンドを使用してください';

ruleTester.run('prefer-destructuring-shorthand', preferDestructuringShorthand, {
  valid: [{ name: '正常系', code: 'const { hoge, foo: test } = obj' }],
  invalid: [
    {
      name: 'オブジェクトの分割代入でショートハンドが可能な時はショートハンドで修正される',
      code: 'const { hoge: hoge } = obj',
      output: 'const { hoge } = obj',
      errors: [{ message: errorMessage }],
    },
    {
      name: 'アロー関数の分割代入でショートハンドが可能なときはショートハンドで修正される',
      code: 'const func = ({ hoge: hoge }) => hoge',
      output: 'const func = ({ hoge }) => hoge',
      errors: [{ message: errorMessage }],
    },
    {
      name: 'functionの分割代入でショートハンドが可能なときはショートハンドで修正される',
      code: 'function func({ hoge: hoge }) { return hoge }',
      output: 'function func({ hoge }) { return hoge }',
      errors: [{ message: errorMessage }],
    },
  ],
});

vitest -c vitest.rules.config.ts runをnpmのタスクとして定義して、そのタスクを実行すると以下のスクショのようにテスト実行ができました。

終わりに

以上が同じ名前で分割代入する場合はショートハンドで強制するESLintのカスタムルールをTypeScriptで作る内容でした。今までカスタムルールはeslint-plugin-local-rulesなど所定の場所に配置して使う必要がありましたが、ESLintが9系になってから直接プラグインとして登録できるようになり、更に最近ではTypeScriptのまま実行できるようになってかなりカスタムルールの導入がやりやすくなったなと思いました。
カスタムルールの作成や分割代入でもショートハンドを強制したい人の参考になれば幸いです。

参考記事

https://zenn.dev/s_takashi/articles/ee7eae7ba80b62

Discussion

ピン留めされたアイテム
cisdurcisdur

本体のルールにno-useless-renameというのがあります。https://eslint.org/docs/latest/rules/no-useless-rename

ぬまさんぬまさん

コメントありがとうございます!ChatGPTに聞いてもないとハッキリ言われて止むを得ずカスタムルールを作りましたが、本体にあるのであればそれを使うのが良いですね。記事の冒頭にもその旨を追記したいと思います。

junerjuner

let の対応とかできないでしょうか……?

const obj = {
  hoge: 'hoge',
  fuga: 100,
};
let hoge:string;
let foo:number;
// ↓こういうの
({ hoge: hoge, fuga: foo } = obj);
ぬまさんぬまさん

letを使ってそんな風に書いて代入することができるんですね!知りませんでした。。
他のケースと同じようにこのコードのASTの構造を解析すればできるので試しにやってみました。

AssignmentExpression > ObjectPattern > Propertyという構造だったのでそれをルールに足したらできました!

prefer-destructuring-shorthand.ts
 import type { Rule } from 'eslint';
 import type { AssignmentProperty, RestElement } from 'estree';

 export const preferDestructuringShorthand: Rule.RuleModule = {
   meta: {
     type: 'suggestion',
     fixable: 'code',
   },
   create(context) {
     // 省略
     return {
       // 他のブロックは省略
+      // let変数にオブジェクトアサインするパターン
+      AssignmentExpression: (node) => {
+        if (node.operator !== '=') {
+          return;
+        }
+        if (node.left.type !== 'ObjectPattern') {
+          return;
+        }
+        checkProperties(node.left.properties);
+      },
+    };
   },
 };