零弐壱蜂

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

背景

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

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

Barrelファイルとは何か

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

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

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

Barrelファイルの利点

Barrelファイルが広く使われてきた理由を挙げる。

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

これらは小規模から中規模のコードベースで十分に機能する。次節で扱う問題は、規模が一定を超えてから顕在化する。

Barrelをやめる主目的はパフォーマンスとAIのためにある

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

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

Tree Shakingの成立条件が増える

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

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

import { ArticleCard } from '.../ArticleCard' を経由してBarrelに到達すると、Barrelで再exportされる全モジュールがロード対象になる。テスト時の初期化コストが膨らみ、dev serverの再評価範囲も広がる。Biomeの noBarrelFile ルールが lint/performance カテゴリに置かれている根拠もここにある。

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

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

主目的を満たすための設計

これらの主目的を満たすには、次の設計をセットで導入する必要がある。

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

推奨ディレクトリ構成

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

src/components/UI/
  ArticleCard.tsx          # 公開入口
  ArticleCard/
    Header.tsx             # 内部実装
    Excerpt.tsx
    Tags.tsx

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

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

@/components/UI/ArticleCardArticleCard.tsx を解決する。ArticleCard/ 配下は内部実装であり、利用側からは触れない。

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/ 配下に置く。

src/components/UI/
  Toast.tsx
  Toast/
    ToastView.tsx
    useToastState.ts

公開入口の 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入口を自明にする点で有用だが、規模が大きくなるとTree Shakingの成立条件を増やし、モジュール初期化を連鎖させ、AIコーディングエージェントの追跡コストを増やす。Barrelを廃止するなら、index.ts を削除するだけでは足りない。同名公開ファイル、named export、private import制限をセットで導入する必要がある。これによりコロケーションを維持しながら、これらのコストを抑えられる。

参考