Custom (local) ESLint rules for TypeScript

この記事は「[イエソド アウトプット筋 トレーニング Advent Calendar 2020](https://adventar.org/calendars/5888)」21日目の記事です。

# Custom (local) ESLint rules for TypeScript

[前回の投稿](https://qiita.com/swallowtail62/items/eddaf1eb3afe8e7c7ff8)でJavaScript向けのeslintの独自ruleの設定の方法について書きました。

今回はTypeScriptプロジェクトでも同様のことができることを示していこうと思います。


## 環境

MacOS Catalina 10.15.7
node 14.15.3


## TypeScriptプロジェクト

せっかく[前回の投稿で用意したリポジトリ](https://github.com/swallowtail62/eslint-local-rule)があるので、それをベースにしていきます。

```terminal
$ git clone git@github.com:swallowtail62/eslint-local-rule.git
$ npm i -D typescript @tsconfig/node14  // tsconfig.jsonのベースに使用
$ npx tsc --init
```

`npx tsc --init`で生成された`tsconfig.json`を修正

```json:tsconfig.json
{
  "extends": "@tsconfig/node14/tsconfig.json",
  "compilerOptions": {
    "outDir": "./dist",
  },
  "include": ["src/**/*.ts", "src/**/*.js"],
  "exclude": ["node_modules"]
}
```

今回はNode14.15.3を使用するので、[Node14系を対象としたtsconfigのベース](https://github.com/tsconfig/bases/blob/master/bases/node14.json)を基底にします。


### Convert js -> ts

以前用意していあったファイルとeslintの設定は、現状こんな感じです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271259/c6410e85-8def-cc11-e3c4-3d61fe5cb86a.png)


```js:.eslintrc.js
module.exports = {
  ... // 省略
  rules: {
    'rulesdir/ban-variable-name': ['error', { words: [ 'hoge', 'fuga' ] }]
  }
};
```


```terminal
$ npx eslint ./src

/Users/path-to-dir/eslint-local-rule/src/index.js
  3:7  error  The keyword hoge is banned to be used as a variable name  rulesdir/ban-variable-name
  9:7  error  The keyword fuga is banned to be used as a variable name  rulesdir/ban-variable-name
  9:7  error  'fuga' is assigned a value but never used                 no-unused-vars
```

custom ruleが効いているのが分かりますね。(spellチェックの警告はスコープ外のため無視)


では、`index.js`の拡張子を`ts`に変更します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271259/da866b43-e76b-91cc-667c-519b63591000.png)

すると、VSCode上でのeslintの警告はなくなりました。

<details>
<summary>tscのエラーはありますが、本題ではないので割愛。</summary>
<div>

```json:tsconfig.json
{
  "compilerOptions": {
    "lib": ["DOM"],
  }
}
```
を追加すればOKです。ただ、今回は["@tsconfig/node14/tsconfig.json"](https://github.com/tsconfig/bases/blob/master/bases/node14.json)をベースとしていて、そちらで`lib`を定義しており、`tsconfig.json`では同一プロパティの宣言はmergeではなくoverride扱いとなるため、

```diff:tsconfig.json
{
  "compilerOptions": {
+    "lib": ["ES2020", "DOM"],
  }
}
```
とする必要があります。
</div>
</details>


さて、cliから今まで通り、eslintを実行してみると、

```terminal
$ npx eslint ./src

Oops! Something went wrong! :(

ESLint: 7.15.0

No files matching the pattern "./src" were found.
Please check for typing mistakes in the pattern.
```

lint対象がないと言われました。eslintでは拡張子の指定がない限り、対象は`*.js`に指定されるからです。
https://eslint.org/docs/user-guide/configuring#specifying-target-files-to-lint

なので、コマンドラインオプションで拡張子を指定して改めて実行してみると

```terminal
$ npx eslint ./src --ext .js,.ts

/Users/path-to-dir/eslint-local-rule/src/index.ts
  3:7  error  The keyword hoge is banned to be used as a variable name  rulesdir/ban-variable-name
  9:7  error  The keyword fuga is banned to be used as a variable name  rulesdir/ban-variable-name
  9:7  error  'fuga' is assigned a value but never used                 no-unused-vars

✖ 3 problems (3 errors, 0 warnings)
```

`js`の頃同様、lint errorが出ました :clap: 
ファイル拡張子的には`ts`でもTypeScirpt特有のシンタックスが出てこない限りは、parserがJavaScript用のものでも問題ないんですね。

ただ、eslint実行の度に`--ext .js,.ts`を指定するのは面倒なので、`package.json`にタスクを作ってしまいます。

```diff:package.json
{
  "scripts": {
+   "lint": "eslint ./src --ext .js,.ts",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
}
```


## parser, parserOptions

```ts
const str: string = 'some string';
```

上記の型アノテーション付きのような、TypeScirpt特有のシンタックスをASTに変換するには、TypeScirptのために用意されたparserを使用する必要があります。
(eslintの設定ファイルで特に何も指定していない場合、デフォルトの[espree](https://github.com/eslint/espree)が使用されます。)
https://eslint.org/docs/user-guide/configuring#specifying-parser

TypeScript用のparserとして、[@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/parser)が用意されているので、ここではこちらを使用します。
(他にも[babel-parser](https://github.com/babel/babel/tree/main/eslint/babel-eslint-parser#typescript)なんかもTypeScriptのparserとして使用できます。)

```terminal
$ npm i -D @typescript-eslint/parser
```


https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/parser#parseroptionsproject に従い、eslint設定ファイルにて、`parser`及び`parserOptions.project`を指定します。

```diff:.eslintrc.js
module.exports = {
  root: true,
  env: {
    node: true,
  },
+ parser: '@typescript-eslint/parser',
  parserOptions: {
    ecmaVersion: 2020,
+   project: './tsconfig.json',
  }
}
```

こうすることでeslintは、`@typescript-eslint/parser`を用いて、`./tsconfig.json`で指定した`include`プロパティを対象にlintをかけてくれます。


```json:tsconfig.json
"include": ["src/**/*.ts", "src/**/*.js"],
```

ただ、`include`に含まれないファイル、ここでは


```terminal
.
├── eslint
│   └── rules
│       └── ban-variable-name.js
└── .eslintrc.js
```

上記の2ファイルをVSCodeで表示している時、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271259/69030da6-8a59-c040-cd12-8f41fc634bbf.png)

のようにproject(lint対象)に含めるよう警告が出ます。この対応としては、
lint対象としたい場合は

```json:tsconfig.json
"include": ["src/**/*.ts", "src/**/*.js", "eslint/**/*.js", ".eslintrc.js"],
```
とすればいいし、lint対象から除外したい場合は、(`.eslintrc.js`なんて意味ないですし)

```:.eslintignore
.eslintrc.js
eslint
```

のようなファイルをプロジェクトルートに配置しておけば、eslintがそれを読み込んでよしなに解釈してくれます。(記法は`.gitignore`等と同じ感じです)



## custom rule 拡張

せっかくなので型を使って何かしら制約をかけようと思いましたが、なかなかいいのが思い浮かばないのと、前回の拡張でいいかなという思いで、
**string型の特定の変数名を禁止する**
という使い道のないクソlintを作りたいと思います。

まずは、`index.ts`を以下のように修正します。

```ts:index.ts
const fuga: number = 100;
```

すると、まだ禁止する型を`string`に限定していないため、eslintのerrorが発生します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271259/62a09a86-55f2-884b-dcd0-7089f02973ab.png)



### ruleをTypeScript化

せっかくなので、前回作成したrule `ban-variable-name` をTypeScript化します。
`@typescript-eslint`がASTの型を提供してくれているので、それを利用します。また、`module`を使用するために`@types/node`もインストールします。


```terminal
$ npm i -D @typescript-eslint/types @types/node
```

https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/types/src/ts-estree.ts
を見てみると各Nodeの型情報が定義されているので、セレクタで指定していた`VariableDeclarator`のNodeの型定義を使用し、TypeScript風にリファクタすると以下のようになります。


```ts:eslint/rules/ban-variable-name.ts
import { TSESTree } from "@typescript-eslint/types";

module.exports = {
  meta: {
    type: "suggestion",
    docs: {
      description: "Ban some keywords from being used as a variable name",
      category: "Variables",
    },
    schema: [
      {
        type: "object",
        properties: {
          words: {
            type: "array",
            items: {
              type: "string",
            },
          },
        },
        additionalProperties: false,
      },
    ],
  },
  create(context) {
    const [option] = context.options;
    const bannedWords = option?.words ?? [];
    return {
      VariableDeclarator: (node: TSESTree.VariableDeclarator) => {
        const { id } = node as { id: TSESTree.Identifier };
        if (bannedWords.includes(id.name)) {
          context.report({
            node,
            message: `The keyword ${id.name} is banned to be used as a variable name`,
          });
        }
      },
    };
  },
};
```

変数Nodeの型情報(TypeAnnotation)は[ここ](https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/types/src/ts-estree.ts#L986)に定義されています。また、型情報は[このように定義](https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/types/src/ts-estree.ts#L534)されています。
なので、string型であるか判定するためのロジックは以下のようになります。

```diff
+ import { TSESTree, AST_NODE_TYPES } from "@typescript-eslint/types";
module.exports = {
  ...
  create(context) {
    const [option] = context.options;
    const bannedWords = option?.words ?? [];
    return {
      VariableDeclarator: (node: TSESTree.VariableDeclarator) => {
        const { id } = node as { id: TSESTree.Identifier };
+       if (
+         bannedWords.includes(id.name) &&
+         id.typeAnnotation?.typeAnnotation.type ===
+           AST_NODE_TYPES.TSStringKeyword
+       ) {
          context.report({
            node,
            message: `The keyword ${id.name} is banned to be used as a variable name`,
          });
        }
      },
    };
  },
```


こうすることで**string型の特定の変数名のみ禁止**というようなruleに変更することができました。あとはこれをトランスパイルして


```terminal
$ npx tsc eslint/rules/ban-variable-name.ts
$ tree eslint
eslint
└── rules
    ├── ban-variable-name.js
    └── ban-variable-name.ts
```

<details>
<summary>トランスパイル後のjsファイルの中身</summary>
<div>

```js:eslint/rules/ban-variable-name.js
"use strict";
exports.__esModule = true;
var types_1 = require("@typescript-eslint/types");
module.exports = {
    meta: {
        type: "suggestion",
        docs: {
            description: "Ban some keywords from being used as a variable name",
            category: "Variables"
        },
        schema: [
            {
                type: "object",
                properties: {
                    words: {
                        type: "array",
                        items: {
                            type: "string"
                        }
                    }
                },
                additionalProperties: false
            },
        ]
    },
    create: function (context) {
        var _a;
        var option = context.options[0];
        var bannedWords = (_a = option === null || option === void 0 ? void 0 : option.words) !== null && _a !== void 0 ? _a : [];
        return {
            VariableDeclarator: function (node) {
                var _a;
                var id = node.id;
                if (bannedWords.includes(id.name) &&
                    ((_a = id.typeAnnotation) === null || _a === void 0 ? void 0 : _a.typeAnnotation.type) ===
                        types_1.AST_NODE_TYPES.TSStringKeyword) {
                    context.report({
                        node: node,
                        message: "The keyword " + id.name + " is banned to be used as a variable name"
                    });
                }
            }
        };
    }
};
```
</div>
</details>


`index.ts`を修正して

```src/index.ts
const fuga: number = 100;

console.log(fuga);  // rule`no-unused-vars`を抑制するため
```

eslintを実行すると

```terminal
$ npm run lint

> eslint-local-rule@1.0.0 lint /Users/path-to-dir/eslint-local-rule
> eslint ./src --ext .js,.ts
```

errorはでません。逆に`string`型に変更すると、

```src/index.ts
const fuga: string = 100;

console.log(fuga);  // rule`no-unused-vars`を抑制するため
```

```terminal
$ npm run lint

> eslint-local-rule@1.0.0 lint /Users/path-to-dir/private/eslint-local-rule
> eslint ./src --ext .js,.ts


/Users/path-to-dir/eslint-local-rule/src/index.ts
  1:7  error  The keyword fuga is banned to be used as a variable name  rulesdir/ban-variable-name

✖ 1 problem (1 error, 0 warnings)

npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! eslint-local-rule@1.0.0 lint: `eslint ./src --ext .js,.ts`
npm ERR! Exit status 1
npm ERR! 
npm ERR! Failed at the eslint-local-rule@1.0.0 lint script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.

npm ERR! A complete log of this run can be found in:
npm ERR!     /Users/~/.npm/_logs/2020-12-21T10_23_32_934Z-debug.log
```

こんな風にerrorとなります。


---

以上で一通りクソeslint ruleを作成できました。

これからruleを`ts`で管理していくとなると、元々git管理していた`js`は不要になるので、commitする前に

```terminal
$ git rm eslint/rules/ban-variable-name.js
```

```diff:package.json
{
  "scripts": {
+   "build:eslint": "tsc eslint/rules/*.ts",
+   "lint": "npm run build:eslint && eslint ./src --ext .js,.ts",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
}
```

とすることで、以降は

```terminal
$ npm run lint
```

でruleのトランスパイルからlint実行までできます。


---

本記事の実装内容は全て[こちらのブランチ](
[こちらのブランチ](https://github.com/swallowtail62/eslint-local-rule/tree/feat/typescript)に全てまとめています。)で進めています。

また、今回は使いませんでしたが、0からTypeScriptでcustom eslint ruleを作成するには、
https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/experimental-utils/src/eslint-utils/RuleCreator.ts
が便利かと思います。

非常に拙い記事になってしまいましたが、以上。