見出し画像

LWCでTypeScriptによる型チェックとESLintによるリントを行う


はじめに

こんにちは荒武です。この記事ではLWCでTypeScriptによる型チェックとESLintによるリントを行うための方法について共有します。
この記事で紹介する設定を行えば、huskyやciツール等と組み合わせてコードの品質を向上させることができます。(記載されているeslintのルールはあくまで一例なのでプロジェクトに応じてカスタマイズすると良いと思われます)
またこちらは「ゆるっとSalesforceトーク #40 LWCで型チェック&lint」の関連記事となっています。(記事の公開が遅れてしまってすみません🙇‍♂️)

以下はこの記事で作成するTypeScriptとESLintのコンフィグでできることです。

TypeScript

  •  厳格な型チェック

  • 公式で未定義なSalesforceのモジュールの型定義の作成とその読み込み

ESLint

  •  import文の順番のアルファベット順による並べ替え

  • 特定のパスパターン(例: lwc, @salesforce, lightning, c)によるimportのグループ化と並べ替え

  • クラスのメンバ(メソッドやプロパティ)の順序の強制

  • デコレーターを持つプロパティやライフサイクルメソッドなどのグループ化と並べ替え

  • 重複するimport文の防止

importとクラスメンバのグルーピング&並び替えは無秩序になりがちなので個人的なイチオシのルールです。

前提

以降の章で説明する手順はvscodeからCreate Projectを実行した後、以下のようなコンポーネントがlwcフォルダにあることを想定しています。

// compA/compA.js

import { isEven } from "c/utils";
import { api, LightningElement } from "lwc";

export default class CompA extends LightningElement {
  connectedCallback() {
    this.prop = "hoge";

    this.num = "1";
  }

  /** @type {string} */
  @api
  prop;

  /** @type {number} */
  num = 0;
}
// compA/__tests__/compA.test.js

import { createElement } from 'lwc';
import CompA from 'c/compA';

describe('c-comp-a', () => {
    afterEach(() => {
        // The jsdom instance is shared across test cases in a single file so reset the DOM
        while (document.body.firstChild) {
            document.body.removeChild(document.body.firstChild);
        }
    });

    it('TODO: test case generated by CLI command, please fill in test logic', () => {
        // Arrange
        const element = createElement('c-comp-a', {
            is: CompA
        });

        // Act
        document.body.appendChild(element);

        // Assert
        // const div = element.shadowRoot.querySelector('div');
        expect(1).toBe(1);
    });
});
// utils/utils.js

/** @param num {number} */
export const isEven = (num) => {
  return num % 2 === 0;
};

型チェック(TypeScript)

この章ではtscコマンドを用いて型定義が用いられているLWCのjsファイル を型チェックする手順について解説します。LWCで型定義を使う方法については以下の記事を参考にされてください。

tscコマンドを実行する

はじめにTypeScriptをインストールします。

npm i -D typescript

次にtsconfig.jsonファイルをlwcフォルダ配下に作成します。

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": false,
    "experimentalDecorators": true,
    "allowJs": true,
    "checkJs": true,
    "noEmit": true,
    "baseUrl": ".",
    "paths": {
      "c/compA": ["compA/compA.js"],
      "c/utils": ["utils/utils.js"]
    },
    "strictPropertyInitialization": false
  },
  "include": ["../../../../.sfdx/typings/lwc/**/*.d.ts", "**/*.js"]
}

それぞれのオプションの役割と解説は以下になっています。
strict: 厳格に型チェックを行いたいのでtrueにしています
noImplicitAny: sfdxプロジェクトによって自動生成される一部の変数に明示的に型が付けられていないのでfalseにしています。

自動生成された変数で明示的に方がつけられていない例

experimentalDecorators: デコレーター(@api,@wire, etc..)を認識させるためにtrueにしています。
allowJs,checkJs: jsファイルの型チェックを行いたいのでtrueにしています。
noEmit: コンパイルは不要なのでfalseにしています。
baseUrl: tsconfigを置いている場所を指定しています
paths: コンポーネント等のマッピング記載します。
strictPropertyInitialization:constructorで初期化されてない@apiのプロパティでエラーが出ないようにするためfalseにしています。
includes: : 自動生成されるLWCの型定義を読み込みとチェックのため../../../../.sfdx/typings/lwc/**/*.d.tsを、jsファイルの型定義と型チェックのために"**/*.js"を記載します。

次にpackage.jsonファイルに型チェックを行うためのスクリプトを追加します。

// package.json

"scripts": {
    ...
    "type-check:lwc": "tsc -p force-app/main/default/lwc/tsconfig.json"
 }

型チェックが行われているか確認するためにコマンドを実行します。

npm run type-check:lwc

numに文字列を代入するところでエラーが出ていることが確認できます。(createElementのエラーの修正方法については次項で解説します)

JSdocによる型付けだけでもいいのですが、このようにtscコマンドで静的解析を行えば、コードベース全体に対してより厳格で網羅的な型チェックを行うことができます。

Salesforceによるモジュールの型定義の作成

Salesforceのモジュールには型定義が自動生成されているものとそうでないものがあります。したがって型定義がないものは自前で作成する必要があります。例えば、前項のテストファイルでエラーが出ていたcreateElementは型定義がないので型定義を作成する必要があります。
はじめにlwcフォルダ配下に型定義を配置するためのフォルダ(@types)を作成します。そして、lwc-engine.d.tsファイルを作成したフォルダ内に作成します。

// @types/lwc-engine.d.ts

declare module "lwc" {
  export const createElement: (name: string, element: { is: any }) => any;
}

作成した@types配下の型定義を読み込めるようにするためtsconfigのincludeに追記します。

{
  "compilerOptions": {
    ...
  },
  "include": [
    ...
    "@types/**/*.d.ts"
  ]
}

再度コマンドを実行するとcreateElementの型エラーが出なくなっていることを確認できます。このように、型定義が存在しないものは適宜追加していきます。

リント(ESLint)

この項目ではESLintを用いてリントを行う手順を解説します。

eslintコマンドを実行する

はじめに.tsファイルにもESLintを実行するのに必要なパッケージをインストールします。

npm i -D typescript-eslint

次にlwc配下に.eslintrc.jsを作成し、以下のように編集します。

// .eslintrc.js

module.exports = {
  extends: [
    "@salesforce/eslint-config-lwc/recommended",
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended"
  ],
  plugins: ["@typescript-eslint"],
  overrides: [
    {
      files: [".eslintrc.js"],
      env: {
        node: true
      }
    }
  ]
};

補足)Eslintではv9.0.0からFlat Configという形式がデフォルトになっていますが、@salesforce/eslint-config-lwcがFlat Configに対応していないので従来どおりの形式でコンフィグを作成しています。

https://eslint.org/docs/latest/use/configure/configuration-files-deprecated

続けてpackage.jsonにlwc配下のファイルにリントをかけるスクリプトを追加します。

// package.json

"scripts": {
  ...
  "type-check:lwc": "tsc -p force-app/main/default/lwc/tsconfig.json",
  "lint:lwc": "eslint -c force-app/main/default/lwc/.eslintrc.json force-app/main/default/lwc"
},

コマンドを実行するとエラーが出ていることが確認できます。

npm run lint:lwc

プラグインとルールの追加

必要なパッケージをインストールします

npm i -D eslint-plugin-import eslint-plugin-sort-class-members

それぞれのパッケージの役割は以下のようになっています。
eslint-plugin-import: importの順番をルール付けるため
eslint-plugin-sort-class-members: クラスのメンバ(メソッドやプロパティ等)の順番をルール付けるため

コンフィグを以下のように更新します。それぞれの役割については下記のコメントを、ルールの詳細等はそれぞれのプラグインのドキュメントを参照してください。

module.exports = {
  extends: [
    "@salesforce/eslint-config-lwc/recommended",
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended"
  ],
  plugins: ["sort-class-members"], //sort-class-membersのルールを利用するため
  overrides: [
    {
      files: [".eslintrc.js"],
      env: {
        node: true
      }
    }
  ],
  rules: {
    // importの順番のルール。アルファベットの昇順でソートする。lwc, @salesforce, lightning, コンポーネントの順でグループ化する。
    "import/order": [
      "error",
      {
        pathGroups: [
          {
            pattern: "lwc",
            group: "builtin",
            position: "after"
          },
          {
            pattern: "@salesforce/**",
            group: "builtin",
            position: "after"
          },
          {
            pattern: "lightning/**",
            group: "builtin",
            position: "after"
          },
          {
            pattern: "c/**",
            group: "external",
            position: "after"
          }
        ],
        alphabetize: {
          order: "asc"
        },
        "newlines-between": "always",
        pathGroupsExcludedImportTypes: [] // pathGroupのgroupでbuiltinとexternalを指定できるようにするため。デフォルト値が["builtin", "external", "object"]になっていてbuiltinとexternalが指定できない。
      }
    ],
    "@typescript-eslint/ban-ts-comment": [
      "error",
      {
        "ts-ignore": false // @ts-ignoreの使用を許可
      }
    ],
    "@typescript-eslint/no-explicit-any": "off", // any型の使用を許可
    // クラスメンバーのソートのルール。@apiデコレーターを持つプロパティやライフサイクルメソッド等をグループ化してorderの通りにソートする
    "sort-class-members/sort-class-members": [
      "error",
      {
        order: [
          "[api-properties]",
          "[static-properties]",
          "[static-methods]",
          "[properties]",
          "[conventional-private-properties]",
          "[accessor-pairs]",
          "[getters]",
          "[setters]",
          "[api-setters]",
          "constructor",
          "[life-cycle-methods]",
          "[api-methods]",
          "[public-methods]",
          "[conventional-private-methods]"
        ],
        groups: {
          "api-properties": [{ groupByDecorator: "api", type: "property" }],
          "api-methods": [{ groupByDecorator: "api", type: "method" }],
          "api-setters": [
            { groupByDecorator: "api", type: "method", kind: "set" }
          ],
          "life-cycle-methods": [
            {
              name: "connectedCallback",
              type: "method"
            },
            {
              name: "disconnectedCallback",
              type: "method"
            },
            {
              name: "renderedCallback",
              type: "method"
            },
            {
              name: "errorCallback",
              type: "method"
            }
          ],
          "public-methods": [
            {
              type: "method",
              private: false,
              static: false,
              kind: "nonAccessor"
            }
          ]
        },
        accessorPairPositioning: "getThenSet"
      }
    ],
    "import/no-duplicates": "error" // import文の重複をエラーとする
  }
};

再度コマンドを実行すると設定したルールに反したコードが見つかります。

vscodeのファイルの保存時に自動で修正

setting.jsonを以下のように編集することで、ファイルの保存時にルールに反しているコードを自動で修正します。(可能なもののみ)

// setting.json

{
  ...
  "eslint.workingDirectories": [
    {
      "directory": "force-app/main/default/lwc",
      "configFile": "force-app/main/default/lwc/.eslintrc.js"
    }
  ],
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "explicit"
  },
  "eslint.validate": ["javascript", "typescript"]
}

それぞれの設定値の役割は以下のようになっています。
eslint.workingDirectories: プロジェクト内で競合しないようにforce-app/main/default/lwcに対してのみforce-app/main/default/lwc/.eslintrc.jsを適用するため
editor.codeActionsOnSave:  保存時に自動修正をかけるため
eslint.validate: js,tsファイルにリントをかけるため

gitignore・forceignoreの更新

自動生成された型定義をバージョン管理できるように.gitignoreを更新します。

// .gitignore
...
# .sfdx/typings とそのサブディレクトリを除外
!.sfdx/typings/
!.sfdx/typings/**

型定義とコンフィグファイルが組織にデプロイされないように.forceignoreを更新します。

// .forceignore
...
**/*.d.ts
**/tsconfig.json
**/.eslintrc.js

完成形

完成形は以下になります。

// tsconfig.json

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": false,
    "experimentalDecorators": true,
    "allowJs": true,
    "checkJs": true,
    "noEmit": true,
    "baseUrl": ".",
    "paths": {
      "c/compA": ["compA/compA.js"],
      "c/utils": ["utils/utils.js"]
    },
    "strictPropertyInitialization": false
  },
  "include": [
    "../../../../.sfdx/typings/lwc/**/*.d.ts",
    "**/*.js",
    "@types/**/*.d.ts"
  ]
}
// .eslintrc.js

module.exports = {
  extends: [
    "@salesforce/eslint-config-lwc/recommended",
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended"
  ],
  plugins: ["sort-class-members"],
  overrides: [
    {
      files: [".eslintrc.js"],
      env: {
        node: true
      }
    }
  ],
  rules: {
    // importの順番のルール。アルファベットの昇順でソートする。lwc, @salesforce, lightning, コンポーネントの順でグループ化する。
    "import/order": [
      "error",
      {
        pathGroups: [
          {
            pattern: "lwc",
            group: "builtin",
            position: "after"
          },
          {
            pattern: "@salesforce/**",
            group: "builtin",
            position: "after"
          },
          {
            pattern: "lightning/**",
            group: "builtin",
            position: "after"
          },
          {
            pattern: "c/**",
            group: "external",
            position: "after"
          }
        ],
        alphabetize: {
          order: "asc"
        },
        "newlines-between": "always",
        pathGroupsExcludedImportTypes: [] // pathGroupのgroupでbuiltinとexternalを指定できるようにするため。デフォルト値が["builtin", "external", "object"]になっていてbuiltinとexternalが指定できない。
      }
    ],
    "@typescript-eslint/ban-ts-comment": [
      "error",
      {
        "ts-ignore": false // @ts-ignoreの使用を許可
      }
    ],
    "@typescript-eslint/no-explicit-any": "off", // any型の使用を許可
    // クラスメンバーのソートのルール。@apiデコレーターを持つプロパティやライフサイクルメソッド等をグループ化してorderの通りにソートする
    "sort-class-members/sort-class-members": [
      "error",
      {
        order: [
          "[api-properties]",
          "[static-properties]",
          "[static-methods]",
          "[properties]",
          "[conventional-private-properties]",
          "[accessor-pairs]",
          "[getters]",
          "[setters]",
          "[api-setters]",
          "constructor",
          "[life-cycle-methods]",
          "[api-methods]",
          "[public-methods]",
          "[conventional-private-methods]"
        ],
        groups: {
          "api-properties": [{ groupByDecorator: "api", type: "property" }],
          "api-methods": [{ groupByDecorator: "api", type: "method" }],
          "api-setters": [
            { groupByDecorator: "api", type: "method", kind: "set" }
          ],
          "life-cycle-methods": [
            {
              name: "connectedCallback",
              type: "method"
            },
            {
              name: "disconnectedCallback",
              type: "method"
            },
            {
              name: "renderedCallback",
              type: "method"
            },
            {
              name: "errorCallback",
              type: "method"
            }
          ],
          "public-methods": [
            {
              type: "method",
              private: false,
              static: false,
              kind: "nonAccessor"
            }
          ]
        },
        accessorPairPositioning: "getThenSet"
      }
    ],
    "import/no-duplicates": "error" // import文の重複をエラーとする
  }
};
// package.json

{
  "name": "salesforce-app",
  "private": true,
  "version": "1.0.0",
  "description": "Salesforce App",
  "scripts": {
    "lint": "eslint **/{aura,lwc}/**/*.js",
    "test": "npm run test:unit",
    "test:unit": "sfdx-lwc-jest",
    "test:unit:watch": "sfdx-lwc-jest --watch",
    "test:unit:debug": "sfdx-lwc-jest --debug",
    "test:unit:coverage": "sfdx-lwc-jest --coverage",
    "prettier": "prettier --write \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"",
    "prettier:verify": "prettier --check \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"",
    "postinstall": "husky install",
    "precommit": "lint-staged",
    "type-check:lwc": "tsc -p force-app/main/default/lwc/tsconfig.json",
    "lint:lwc": "eslint -c force-app/main/default/lwc/.eslintrc.js force-app/main/default/lwc"
  },
  "devDependencies": {
    "@lwc/eslint-plugin-lwc": "^1.1.2",
    "@prettier/plugin-xml": "^3.2.2",
    "@salesforce/eslint-config-lwc": "^3.2.3",
    "@salesforce/eslint-plugin-aura": "^2.0.0",
    "@salesforce/eslint-plugin-lightning": "^1.0.0",
    "@salesforce/sfdx-lwc-jest": "^5.1.0",
    "eslint": "^8.57.0",
    "eslint-plugin-import": "^2.30.0",
    "eslint-plugin-jest": "^28.8.1",
    "eslint-plugin-sort-class-members": "^1.20.0",
    "husky": "^9.1.5",
    "lint-staged": "^15.1.0",
    "prettier": "^3.1.0",
    "prettier-plugin-apex": "^2.0.1",
    "typescript": "^5.6.2",
    "typescript-eslint": "^8.8.0"
  },
  "lint-staged": {
    "**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}": [
      "prettier --write"
    ],
    "**/{aura,lwc}/**/*.js": [
      "eslint"
    ]
  }
}

おわりに

補足にはなりますが公式のロードマップ(下記動画参照)によるとSpring'25からLWCのTypeScript対応が行われるようなので、この記事で行っているような型チェックのための設定は不要になるかもしれません。期待が高まりますね🙏(公式な状態管理の仕組みも楽しみ)