零弐壱蜂

[TypeScript] パフォーマンスとAI AgentのためにBarrelファイルを廃止する

背景

ReactやTypeScriptで書かれたUIコンポーネント群を index.ts でまとめるBarrelファイル構成は広く使われている。利用側はフォルダ単位でimportでき、公開APIの境界もコードを読むだけで把握できる。

ただしこの構成は、コンポーネントが数十から数百規模に達すると、Tree Shakingの効きにくさ・テストやdev serverの初期化連鎖・AIコーディングエージェントによるコード追跡コストを生む。Barrelの利点である「入口の自明性」を保ったまま、この3つのコストを抑える設計を組めるか。

Barrelファイルは再exportを集約する入口である

Barrelファイルとは、index.ts などで同一ディレクトリ内のexportをまとめて再exportするファイルである。

export { ArticleCard } from './ArticleCard';
export { ArticleCardHeader } from './ArticleCardHeader';

利用者は @/components/UI/ArticleCard のようにフォルダ単位でimportする。

Barrelファイルは小規模では入口と公開APIを読みやすくする

Barrelファイルが広く使われてきた理由は、入口と公開範囲をまとめられることだ。

  • import入口が自明である。利用者はフォルダ名でimportでき、内部のファイル名を意識しなくてよい。
  • 公開APIの設計がLinterなしで伝わる。index.ts に書かれたexportが公開API、書かれていないものは内部実装、という規約がコードを読むだけで把握できる。規約ドキュメントやlintルールがなくても新規参加者に直感的である。
  • 内部構成の変更を隠蔽できる。内部のファイル分割やリネームを index.ts で吸収すれば、利用側のimportパスは変わらない。
  • import文の形式が揃う。利用側の import { Foo } from '@/components/UI/Foo' が一定のフォーマットになり、人間にとって読みやすい。

小規模から中規模のコードベースでは、この利点は十分に機能する。問題は、コンポーネント数と依存関係が増えた後に顕在化する。

Barrel廃止は性能とコード追跡コストを下げるために行う

Barrelをやめる目的は、単に index.ts を削除することではない。狙いは次の3点である。

  1. Tree Shakingを妨げる条件を増やさない
  2. モジュール初期化の連鎖を断ち切る
  3. AIコーディングエージェントの追跡コストを下げる

Tree Shakingが効きにくい条件が増える

Barrelは複数モジュールの再exportを集約するため、バンドラが追跡するモジュールグラフを拡大させやすい。webpack の公式ドキュメントによれば、Tree ShakingはESM構文の静的構造・sideEffects 指定・production最適化の組み合わせに依存する。Barrel経由では追跡対象が増えるため、利用していないexportがバンドルに残る場面が出やすい。

モジュール初期化が連鎖する

import { ArticleCard } from '@/components/UI/ArticleCard' のようにBarrelに到達すると、Barrelで再exportされるモジュールが依存グラフに入り、実行環境や最適化設定によっては読み込み・評価対象になる。テスト時の初期化コストが膨らみ、dev serverの再評価範囲も広がる。Biomeの noBarrelFile ルールが lint/performance カテゴリに置かれている根拠もここにある。

AIコーディングエージェントの追跡性が落ちる

ランタイムだけでなく、コード解析の文脈でも同じ問題が出る。コーディングエージェントがシンボル定義をたどるとき、Barrel経由だと「Barrelファイルを開く→再export元を読み込む」の二段を踏む。コンテキストウィンドウに不要な再export文が含まれ、トークン消費と読み取りステップが増える。直接importであれば、エージェントは公開ファイル1つを読めば済む。

禁止・公開入口・依存境界をセットで決める

この3点を満たすには、次の設計をセットで導入する必要がある。

  1. Barrelファイルを禁止する
  2. 公開入口は同名ファイルにする
  3. 内部実装は同名ディレクトリに置く
  4. exportはnamed exportに寄せる
  5. BiomeでBarrel・default export・private importを制限する
  6. 厳密な依存境界はdependency-cruiserなどの専用ツールで補う

公開入口は同名ファイル、内部実装は同名ディレクトリに置く

公開入口を同名ファイルとし、内部実装を同名ディレクトリに置く。テストとStorybookは対象ファイルと同階層に置く。

src/components/UI/
  ArticleCard.tsx              # 公開入口
  ArticleCard.test.tsx         # 公開入口のテスト
  ArticleCard.stories.tsx      # Storybook
  ArticleCard/
    Header.tsx                 # 内部実装
    Header.test.tsx            # 内部実装のテスト
    Excerpt.tsx
    Tags.tsx

利用側はフォルダではなく公開ファイルを直接importする。

import { ArticleCard } from '@/components/UI/ArticleCard';

@/components/UI/ArticleCard は、paths とバンドラ側の拡張子なし解決が有効な環境では ArticleCard.tsx を解決する。ArticleCard/ 配下は内部実装であり、利用側からは触れない。

テストとStorybookの配置方針

テストとStorybookも同名ファイル・同名ディレクトリの規約に従う。

同階層に置く理由は、実装・利用例・検証を同じ変更単位として扱うためである。公開入口を変更するとき、対応するテストとstoryが隣にあれば、公開APIの破壊的変更や表示状態の更新漏れに気付きやすい。Storybook公式も、storyファイルをコンポーネントファイルの隣に置く構成を示している。Vitestもテストファイルの置き場所に唯一の正解はないとしつつ、ソースの隣に置く構成を例示している。

  • 公開入口のテストは公開ファイルと同階層に置き、公開動作を検証する。
  • 内部実装のテストは内部ファイルと同階層に置き、相対パス(./Header など)でimportする。
  • Storybookは公開コンポーネントに対してのみ作成し、公開ファイルと同階層に置く。内部実装は公開しないため、storyは書かない。

この配置はBarrel廃止後の設計と整合する。内部実装のテストは相対パスでimportするため、後述する noRestrictedImports の対象外になる。外部のテストファイルからは公開ファイルだけをimportするため、Barrel廃止後のimport規約と一致する。

Barrel廃止後は性能面で有利だが規約が必要になる

Barrel構成と廃止後の構成を比較すると、性能面と運用面のトレードオフが見える。

観点Barrel構成Barrel廃止後の構成
Tree Shaking依存グラフが広がりやすい直接ファイルに限定しやすい
モジュール初期化Barrel経由で連鎖しやすい直接依存を中心に評価される
dev serverの再評価範囲が広がりやすい範囲を狭めやすい
AI解析の追跡性Barrelを1段挟む直接ファイルへ到達する
利用者の迷い少ない規約とlintが必要
default export名前揺れが起きやすいnamed exportで抑制できる
内部importBarrelが心理的な境界になるlintなしだと崩れやすい
移行コスト低いimportの書き換えが必要
ルール強制弱いBiomeなどで補強する

Barrel廃止後はTree Shaking・モジュール初期化・AI解析の3点で有利になりやすいが、利用者の迷いやルール強制の弱さを規約とlintで補う必要がある。

Toastコンポーネントでは公開入口と内部実装を分ける(実装例)

公開入口は同名ファイル Toast.tsx、内部実装は Toast/ 配下に置く。テストとStorybookも同階層に配置する。

src/components/UI/
  Toast.tsx                    # 公開入口
  Toast.test.tsx               # 公開動作のテスト
  Toast.stories.tsx            # Storybook
  Toast/
    ToastView.tsx              # 内部実装
    useToastState.ts
    useToastState.test.ts      # hookの単体テスト

公開入口の Toast.tsx は、内部実装を組み立て、公開コンポーネントを返す。

// src/components/UI/Toast.tsx
import { ToastView } from './Toast/ToastView';
import { useToastState } from './Toast/useToastState';

export function Toast() {
  const state = useToastState();
  return <ToastView state={state} />;
}

利用側はnamed exportでimportする。

import { Toast } from '@/components/UI/Toast';

避けるべきimportは、内部実装への直接importである。

// 避ける: 公開APIを経由していない
import { ToastView } from '@/components/UI/Toast/ToastView';

このimportは公開APIを経由していないため、後から内部構成を変えにくくなる。ToastView を分割した瞬間、利用側のimportパスが破綻する。Barrelが暗黙に提供していた「内部構成の隠蔽」は、Barrel廃止後はBiomeの内部import制限で部分的に代替される。完全な隠蔽には別途dependency-cruiserなどで補強する必要がある(後述)。

環境

  • Biome 2.4.14
  • Next.js 16.2.4

なお、noRestrictedImports.options.patterns はBiome v2.2.0以降、overrides[].includes はBiome v2.xの記法であるため、Biome 1.xでは動作しない。

Biomeで制約を強制する

設計を口約束で運用するとすぐ崩れる。lintで強制する必要がある。

ESLintでも同等の制約は組めるが、次のように複数プラグインの組み合わせが必要になる。

制約ESLintプラグイン
Barrel禁止eslint-plugin-barrel-files
default export禁止eslint-plugin-import
private import制限eslint-plugin-boundaries
型import統一@typescript-eslint

設定が分散するため、単一のlinterで完結させられるBiomeをベースとする。

Biomeで以下のルールを有効化し、Barrel・default export・private importを構造的に制限する。

{
  "linter": {
    "rules": {
      "performance": {
        "noBarrelFile": "error",
        "noNamespaceImport": "warn",
      },
      "style": {
        "noDefaultExport": "error",
        "useImportType": "warn",
        "useExportType": "warn",
        "noRestrictedImports": {
          "level": "error",
          "options": {
            "patterns": [
              {
                "group": ["@/**/index", "@/**/index.*"],
                "message": "index/barrel importは禁止です。公開ファイルを直接importしてください",
              },
              {
                "group": ["@/components/UI/*/*", "@/components/UI/*/**", "!@/components/UI/Layout/*"],
                "message": "UI moduleの内部ファイルを直接importしないでください",
              },
            ],
          },
        },
      },
    },
  },
}

各ルールの役割は以下の通りである。

  • noBarrelFile: 再exportのみのファイルを禁止する
  • noDefaultExport: default exportを禁止し、named exportに寄せる
  • noRestrictedImports: index.* への依存と、UI/*/* のような内部ファイルへの直接importを禁止する
  • useImportType / useExportType: 型のimport・exportを import type / export type に統一する

Next.jsの page.tsxlayout.tsx はdefault exportが前提のため、ファイル単位で例外を設定する。

{
  "overrides": [
    {
      "includes": ["src/app/**/page.tsx", "src/app/**/layout.tsx"],
      "linter": {
        "rules": {
          "style": {
            "noDefaultExport": "off",
          },
        },
      },
    },
  ],
}

なお、noRestrictedImports のpatternsは @/... のようなエイリアス経由のimportに適用される。同一モジュール内からの相対パスでの内部import (例: ./Toast/ToastView 形式) は対象外であるため、Biomeだけでは相対パス経由の参照までは塞げない。相対importの境界制御や循環依存の検出が必要であれば、dependency-cruiserなどの専用ツールで補う。

まとめ

Barrelファイルはimport入口を自明にする点で有用だが、規模が大きくなると依存グラフを広げ、モジュール初期化を連鎖させ、AIコーディングエージェントの追跡コストを増やす。Barrelを廃止するなら、index.ts を削除するだけでは足りない。同名公開ファイル、named export、private import制限をセットで導入する必要がある。この組み合わせにより、コロケーションを維持しながら性能とコード追跡のコストを抑えられる。

参考