Angularでディレクトリ単位の依存を禁止するルールをESLintで実現する


ng-japan OnAir を視聴していて、便利そうな Lint のプラグインを知った。

[#64 【ゲスト: okunokentaro】Webアプリケーション設計とディレクトリ構造についてとことん語る！【雑談回】](https://github.com/ng-japan/onair/discussions/75)
![](https://files.speakerdeck.com/presentations/257b95cee2394c62af3acaf7982f9263/slide_23.jpg?25002548)

> このルールを採用すると「任意のディレクトリから別のディレクトリへの依存を禁止する」ことができる

なんと・・・・！知らなかった・・・！！！

今まで Lint に頼っていた「ファイル保存時に不要なインポート文を削除」と「ファイル保存時に Prettier でコード整形」も残しつつ、さらに「ディレクトリ単位で依存を禁止」できれば、めちゃくちゃ便利・・・。

ということで、この記事にてまっさらな Angular プロジェクトを作って Lint を動かすまでの手順をメモしておく。

# 記事を書いた環境

- VSCode の利用を前提
- Angular v15
- node 18

# 手順

## Angular プロジェクトの作成

プロジェクト作成時の質問は当記事の内容に関与しないので、適当なものを選択する。

```shell
npx -p @angular/cli@15 ng new

? What name would you like to use for the new workspace and initial project?
> demo
? Would you like to add Angular routing?
> No
? Which stylesheet format would you like to use?
> CSS
...

✔ Packages installed successfully.
    Successfully initialized git.
```

## ESLint のセットアップ

初回の Lint 実行で Schematics が ESLint をセットアップしてくれる。便利。

```shell
npm run ng lint

? Would you like to share pseudonymous usage data about this project ...
> No
Would you like to add ESLint now?
> Yes
Would you like to proceed?
> Yes
...

✔ Packages installed successfully.
```

## ESLint のプラグインをインストール

もろもろのプラグインをインストールする。これらが冒頭のルール群を実現してくれる。

```shell
npm install -D \
  eslint-import-resolver-typescript\
  eslint-plugin-angular\
  eslint-plugin-import\
  eslint-plugin-prettier\
  prettier\
  prettier-eslint\
  eslint-config-prettier\
  eslint-plugin-unused-imports
```

<details><summary>記事を書いた時点のバージョン</summary>


```shell
npm install -D \
  eslint-import-resolver-typescript@3.5.5\
  eslint-plugin-angular@4.1.0\
  eslint-plugin-import@2.27.5\
  eslint-plugin-prettier@4.2.1\
  prettier@2.8.7\
  prettier-eslint@15.0.1\
  eslint-config-prettier@8.8.0\
  eslint-plugin-unused-imports@2.0.0
```

</details>

## Prettier の 設定ファイルを作成

設定ファイルを作成する。内容はお好みで。

```js:prettier.config.js
module.exports = {
  trailingComma: "es5",
  tabWidth: 4,
  semi: false,
  singleQuote: true,
};
```

## ESLint の 設定ファイルを作成

Angular が `.eslint.json` を用意してくれるが JavaScript スタイルのほうが好みのためファイルを変更する。

```js:.eslintrc.js
module.exports = {
  ignorePatterns: [
    "/node_modules",
    "/dist",
    "**/*.html",
    "**/*.scss",
    // 設定ファイル群を除外
    "*rc.js",
    "*.config.js",
  ],
  root: true,
  extends: [
    // Prettier の設定を ESLint のルールとして読み込む
    "eslint:recommended",
    "plugin:prettier/recommended",
    "plugin:import/recommended",
    "plugin:@angular-eslint/recommended",
    "plugin:@typescript-eslint/recommended",
  ],
  parser: "@typescript-eslint/parser",
  parserOptions: {
    project: "./tsconfig.json",
  },
  // インストールしたプラグイン群を読み込む
  plugins: ["import", "unused-imports", "prettier"],
  overrides: [
    // .ts ファイルを解決する
    {
      files: ["*.ts"],
      settings: {
        "import/resolver": {
          typescript: {
            alwaysTryTypes: true,
            project: ["./tsconfig.json"],
          },
        },
      },
      rules: {
        // import/no-restricted-paths と衝突しないための設定
        "@typescript-eslint/no-unused-vars": "off",
        // 不要なインポート文を削除
        "unused-imports/no-unused-imports": "error",
        // インポート文を並べ替え
        "import/order": ["error", { alphabetize: { order: "asc" } }],
        // ディレクトリ参照の禁止ルール
        "import/no-restricted-paths": [
          "error",
          {
            zones: [
              {
                target: "./src/app/models",
                from: "./src/app/!(models)/*",
                message: "モデルはこのフォルダからインポートできません",
              },
              {
                target: "./src/app/repositories",
                from: "./src/app/components",
                message: "リポジトリはコンポーネントをインポートできません",
              },
            ],
          },
        ],
      },
    },
  ],
};
```

## Lint を一度実行

Angular の雛形コード群に ESLint のルールを適用しておく。

```shell
npm run lint -- --fix
```

## VSCode のエクステンションをインストール

冒頭の「ファイル保存時に〜」を実現させるため [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) のエクステンションをインストール。
設定に以下を追記する。

```ts:コマンドパレット > Preferences: Open User Settings（JSON)
"editor.codeActionsOnSave": {
    // ESLint の推奨する内容でコードを上書きする
    "source.fixAll.eslint": true,
  },
  // .ts ファイルの保存時に ESLint のルールにそってフォーマットする
  "[typescript]": {
    "editor.formatOnSave": true,
    "editor.defaultFormatter": "dbaeumer.vscode-eslint"
  },
```

# 試してみる

Lint の挙動を確認するため、単純なモデル・リポジトリ・コンポーネントのサンプルコードを用意した。

```ts:src/app/models/demo.model.ts
export type Demo = { id: number }
```

```ts:src/app/repositories/demo.repository.ts
import { Injectable } from '@angular/core'
import { Demo } from 'src/app/models/demo.model'

@Injectable({
    providedIn: 'root',
})
export class DemoRepository {
    fetch(): Demo {
        return { id: 1 }
    }
}
```

```ts:src/app/components/demo.component.ts
import { Component, inject } from '@angular/core'
import { DemoRepository } from 'src/app/repositories/demo.repository'

@Component({
    selector: 'app-demo',
    template: ``,
})
export class demoComponent {
    private readonly repository = inject(DemoRepository)
}
```

ディレクトリ構造は以下のとおり。
依存方向はコンポーネント > リポジトリ > モデルの一方向とする。

```
├── src
│   ├── app
│   │   ├── components
│   │   │    └── demo.component.ts
│   │   ├── models
│   │   │    └── demo.model.ts
│   │   ├── repositories
│   │   │    └── demo.repository.ts
```

## ESLint 再起動

設定をいろいろ更新しているのでコマンドパレットから ESLint を再起動しておく。
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102284/1bc968db-0c52-af2a-5403-87a988ae031c.png)

## ファイル保存時に Prettier でコード整形

適当に崩してみる。

```ts:demo.model.ts
export 
  type Demo       = {
           id: number}
```

インデントやスペースなどファイル保存時に整形される。

```ts
+ export type Demo = {
+     id: number
+ }
```

## ファイル保存時に不要なインポート文を削除

ロジックの削除などによって不要になったインポートを想定して、1 行追加してみる。

```ts:demo.component.ts
import { Component, inject } from '@angular/core'
import { DemoRepository } from 'src/app/repositories/demo.repository'
import { Demo } from 'src/app/models/demo.model' // 適当にインポートを追加
```

ファイル保存時に該当のインポートが削除される。

```ts
import { Component, inject } from '@angular/core'
import { DemoRepository } from 'src/app/repositories/demo.repository'
+ // 削除
```

## ディレクトリ単位で依存を禁止

リポジトリからコンポーネントへの依存を追加してみる。マイルールとして定めた「依存方向はコンポーネント > リポジトリ > モデルの一方向」に違反するもの。

```ts:src/app/repositories/demo.repository.ts
import { Injectable } from '@angular/core'
import { Demo } from 'src/app/models/demo.model'
import { demoComponent } from 'src/app/components/demo.component' // 依存方向に違反したもの

@Injectable({
    providedIn: 'root',
})
export class DemoRepository {
    private readonly component = new demoComponent()
    ...
```

`.eslintrc.js` の import/no-restricted-paths によってディレクトリ単位で特定のディレクトリから別のディレクトリへの依存が違反として検出される。即時に赤い破線がひかれるので違反が見つけやすく、メッセージをカスタムできるので内容が分かりやすい。

```tsx
import { Injectable } from '@angular/core'
import { Demo } from 'src/app/models/demo.model'
import { demoComponent } from 'src/app/components/demo.component'
                              ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// リポジトリはコンポーネントをインポートできません eslint(import/no-restricted-paths)

@Injectable({
    providedIn: 'root',
})
export class DemoRepository {
    private readonly component = new demoComponent()
    ...
```

# 感想

Lint はもはや単なるシンタックスチェッカーでなく、開発体験を向上させるべくどんどん進化している。

今回の設定内容の便利さを体感するにはエディタのエクステンションが必須なので `.extensions.json` などを使ってチームでシェアしたい。

```json:.extensions.json
{
  "recommendations": [
    "dbaeumer.vscode-eslint"
  ]
}
```