💥

VSCode で TSServer がクラッシュしまくる巨大モノレポでの TypeScript 型検査改善

に公開

はじめに

The JS/TS language service immediately crashed 5 times.

The JS/TS language service crashed 5 times in the last 5 Minutes...

VSCode や Cursor などでこのようなログ、見たことありますか?

私たちのプロジェクトでは、このダイアログが 1 日に何度も表示され、開発体験を著しく損なっていました。参照ジャンプは永遠にローディング、型チェックは遅延、そして TSServer は頻繁にクラッシュ。

この記事では、巨大な monorepo プロジェクトで経験した TSServer のパフォーマンス問題と、それを解決するために行った4 つの改善施策について共有します。

最終的に、TSServer のクラッシュを完全に解消し、ファイル単位ではボトルネックとなっていた型チェック速度を最大 258 倍高速化することができました


序章:TSServer クラッシュ地獄の日々

プロジェクト規模

私たちのクライアント企業用の管理画面プロジェクトは以下のような構成の monorepo です:

  • ワークスペース: 5 つ(うち 1 つは shared な共通ライブラリ)
    • それぞれが tsconfig.json を持つ
  • ファイル数: 各ワークスペース 1000 ファイル以上
  • 技術スタック: Vue3 + TypeScript + Nuxt4
シェアフルのフロントエンド/
├── workspace1/     # クライアント企業向けwebアプリ
├── workspace2/      # 社内営業向けwebアプリ
├── workspace3/      # 管理者向けwebアプリ
├── workspace4/       # 求職者向けwebアプリ
└── shared/         # 共通コンポーネント・ユーティリティ

発生していた問題

上記のようにかなり巨大であり、ある時から以下のような問題が頻発していました:

  1. TSServer が頻繁にクラッシュ: Cursor/VSCode で上記のダイアログが何度も表示
  2. 参照ジャンプが激遅: 関数定義へジャンプしようとすると数秒〜永遠にローディング
  3. 型チェックが異常に遅い: vue-tsc で特定ファイルの型チェックに 8-10 秒かかる

暫定対策(それでもクラッシュは止まらず)

まず、応急処置として以下を試みました:

TSServer のメモリ上限を増加

// .vscode/settings.json
{
  "typescript.tsserver.maxTsServerMemory": 8192
}

デフォルトの 3072MB から 8192MB に増加。一時的にクラッシュ頻度は下がりましたが、根本解決には至りませんでした。残念ながら焼け石に水 😂

Multi-root Workspace で分離

Multi-root Workspaces

// sharefull.code-workspace
{
  "folders": [
    { "path": "workspace1" },
    { "path": "workspace2" },
    { "path": "workspace3" },
    { "path": "workspace4" },
    { "path": "shared" }
  ]
}

マルチルートワークスペースの本来の使い方ではない気がしますが、TSServer のメモリをさらに増やすためにこの措置を取りました。
各ワークスペースを別々の TSServer インスタンスで動かすことで、負荷を分散。これもある程度効果はありましたが、完全な解決には至りませんでした。。

とういわけで流石に開発に支障が出過ぎな感じがあったので、本腰を入れた改善を行うことにしました。


改善 1:循環参照を潰す

問題の発見

アーキテクチャを分析したところ、共有ライブラリ(shared)が具象ワークスペース(アプリ群)に依存しているという問題を発見しました。

これは「抽象・共有ライブラリは具象の内容を知るべきではない」という依存関係の原則に違反しています。

これにより、「各ワークスペースを開くたびに全ワークスペースの型検査実行」→「メモリ不足でクラッシュ」→「再実行」→「またクラッシュ」といった最悪の結果になっていました。

その証拠として、 workspaceA や shared のファイルを開いた際に workspaceB,C,D の tsconfig が同時に初期化されている様子がエディタ上で見られることがありました。

具体的な依存パターン

調査の結果、以下の 5 つのパターンで shared が*-web に依存していることが判明しました:

例: queries の union

各ワークスペースの GraphQL 型を shared で集約していました。

// shared/types/orders/_id/index.d.ts
import type { App1Query } from '@workspace1/queries/types';
import type { App2Query } from '@workspace2/queries/types';
import type { App3Query } from '@workspace3/queries/types';

export type Query = App1Query | App2Query | App3Query;

型のみの依存も、TSServer にとっては負荷となります。

codegen で生成するファイルや逆依存のロジックは shared に配置するようにして、依存を徹底的に排除しました。

結果

アーキテクチャは健全化し、ビルドエラーも解消されました。

しかし、TSServer のクラッシュは止まらなかった...


改善 2:計測する仕組みの確立

計測方法の確立

「計測なくして改善なし」と誰かが言っていた気がします。

まず、何が遅いのかを可視化するため、vue-tsc の--generateTraceオプションを使用しました。

# トレースファイルを生成
vue-tsc --generateTrace ./trace --extendedDiagnostics

--generateTrace は tsc で使用するのが一般的ですが、実は vue-tsc でも同様に実行でき、コンポーネントの型検査の実行時間も詳しく分析することができます。

生成されたtrace.jsonを Chrome DevTools の Performance タブで開くと、どのファイルの型チェックに時間がかかっているかが一目瞭然になります。

発見した問題

トレース分析の結果、以下のファイルが異常に重いことが判明しました:

ファイル 型チェック時間
ComponentA.vue 9,434ms
ComponentB.vue 7,739ms

明らかに特定のファイルだけが異常に遅い。これは何かある...

測定結果、明らかに重たいファイルが2つほど見つかりました。

重すぎる。。

ボトルネックの切り分け手法

次にこれらのファイルのどこが重たいのか、重たい箇所の共通点が存在するのかを分析しました。

  1. コメントアウト法: 怪しいファイルをコメントアウトしながら、「この 1 ファイルを消すと何秒速くなるか」を計測
  2. 重いファイルランキング: トレース結果から、型チェック時間の長いファイルをリストアップ
  3. 依存関係の可視化: 重いファイルがどこから import されているかを追跡
# 例:特定ディレクトリだけを型チェック
vue-tsc --project tsconfig.json --generateTrace ./trace workspace1/features/orders/

地道な作業ですが、これにより確実にボトルネックとなるファイルやコードをあぶり出すことができます。
その箇所については次項目にて書いています。


改善 3:重い型推論の最適化

問題の特定

計測で特定したComponentA.vueとComponentB.vueを詳しく調査したところ、ある共通点を発見しました。

具体的には、どちらも master.ts というファイルの m('') 関数を使用していたことでした。

// 問題のあったコード
import { m } from '@/shared/master';

// ここが遅すぎる!
const orderStatus = m('xxxStatus');
const prefectures = m('prefectures');
const occupations = m('occupations');

原因の深掘り

m() 関数の実装を確認したところ、以下のような型定義になっていました:

// master.ts(簡略化)
const MASTERS = {
  orderStatus: [
    { id: 1, name: '募集中' },
    { id: 2, name: '終了' },
  ],
  prefectures: [{ id: 1, name: '北海道' } /* ... 47都道府県 */],
  occupations: [{ id: 1, name: 'エンジニア' } /* ... 数百の職種 */],
  // ... 数十種類のマスターデータ
} as const;

type MasterKey = keyof typeof MASTERS;

export function m<K extends MasterKey>(key: K): (typeof MASTERS)[K] {
  return MASTERS[key];
}

一見すると賢い実装ですが、m('') を呼び出すたびに、TSServer は全マスターデータの型を推論しなければならないという問題がありました。

マスターデータが数十種類、各データが数百件あると、型推論のコストが爆発的に増大します。

解決策

1. master.ts をドメインごとに分割

// master/order.ts
export const MASTER_ORDER_STATUS = [
  { id: 1, name: '募集中' },
  { id: 2, name: '終了' },
] as const;

// master/location.ts
export const MASTER_PREFECTURES = [
  { id: 1, name: '北海道' },
  // ...
] as const;

2. 動的な関数呼び出しを直接参照に変更

// Before: 全マスターの型を推論してしまう
import { m } from '@/shared/master';
const orderStatus = m('orderStatus');

// After: 必要な型だけを参照
import { MASTER_ORDER_STATUS } from '@/shared/master/order';
const orderStatus = MASTER_ORDER_STATUS;

改善結果

ファイル Before After 改善率
A.vue 9,434ms 99ms 95 倍
B.vue 7,739ms 30ms 258 倍
総時間 38s 21s 1.8 倍

型チェックが劇的に高速化しました!特定のファイルで 100 倍以上の改善です。

これで勝ったと思ったのですが、しかし、TSServer のクラッシュはまだ起きていた...

何が TSServer を殺し続けているのか?答えは、別の箇所にありました。


改善 4:バレルファイル排除 - ついに完全勝利!

バレルファイルとは

バレルファイル(Barrel File)とは、複数のモジュールを 1 つのエントリーポイントから再エクスポートするパターンです。

// components/index.ts(バレルファイル)
export { Button } from './Button/Button.vue';
export { Input } from './Input/Input.vue';
export { Modal } from './Modal/Modal.vue';
export { Card } from './Card/Card.vue';
// ... 数十〜数百のexport

使う側は便利:

// バレルファイル経由でimport
import { Button, Input, Modal } from '@/components';

一見、コードがスッキリして良さそうに見えます。

なぜバレルファイルが TSServer を殺すのか

しかし、バレルファイルには TSServer にとって致命的な問題があります:

  1. 1 つの import で全 export の型を解析: import { Button } from '@/components' と書くと、TSServer は index.ts が持つ全てのexport の型を解析する
  2. 連鎖的な型推論: 各 export がさらに他のファイルに依存している場合、依存関係を全て辿る
  3. メモリ圧迫: 解析結果をメモリに保持するため、大規模プロジェクトではメモリを圧迫
  4. ツリーシェイキングの阻害: ビルド時にも不要なコードが含まれやすい
  5. 循環参照のリスク

私たちのプロジェクトでは、数十のバレルファイルがあり、各ファイルが数十〜数百のコンポーネントを export していました。TSServer にとっては地獄のような状況だったのです。

参考リンク

変換スクリプトの作成

数百ファイルを手動で変換するのは現実的ではありません。そこで、ts-morph を使った自動変換スクリプトを作成しました。

import { Project } from 'ts-morph';
import * as fs from 'fs';
import * as path from 'path';

const ALIAS_MAP: Record<string, string> = {
  '@sharefull/shared': 'shared',
  '@workspace1': 'workspace1',
  '@workspace2': 'workspace2',
  // ...
};

/**
 * バレルファイルからのimportかどうか判定
 */
function isBarrelImport(importPath: string): boolean {
  const resolved = resolveAliasPath(importPath);

  // index.tsが含まれるか、ディレクトリパスで終わっている
  if (resolved.includes('index')) {
    return true;
  }

  // ディレクトリにindex.tsが存在するか確認
  const indexPath = path.join(resolved, 'index.ts');
  return fs.existsSync(indexPath);
}

/**
 * コンポーネント名から実際のファイルパスを検索
 */
function findComponentPath(
  componentName: string,
  componentMap: ComponentMap
): string | null {
  const candidates = componentMap[componentName];
  if (!candidates || candidates.length === 0) {
    return null;
  }

  // .vueファイルを優先
  const vueCandidates = candidates.filter((p) => p.endsWith('.vue'));
  return vueCandidates[0] || candidates[0];
}

/**
 * importを変換
 */
function convertImports(sourceFile: SourceFile, componentMap: ComponentMap) {
  const imports = sourceFile.getImportDeclarations();

  for (const importDecl of imports) {
    const moduleSpecifier = importDecl.getModuleSpecifierValue();

    if (!isBarrelImport(moduleSpecifier)) {
      continue;
    }

    const namedImports = importDecl.getNamedImports();
    const newImports: string[] = [];

    for (const named of namedImports) {
      const name = named.getName();
      const directPath = findComponentPath(name, componentMap);

      if (directPath) {
        // 直接importに変換
        newImports.push(
          `import ${name} from '${convertToAliasPath(directPath)}'`
        );
      }
    }

    if (newImports.length > 0) {
      // 元のimportを削除し、新しいimportを追加
      importDecl.remove();
      // ... 新しいimportを挿入
    }
  }
}

変換例

// Before: バレルファイル経由
import { Button, Input, Modal } from '@/components';
import { useAuth, useUser } from '@/composables';

// After: 直接import
import Button from '@sharefull/shared/components/atoms/Button/Button.vue';
import Input from '@sharefull/shared/components/atoms/Input/Input.vue';
import Modal from '@sharefull/shared/components/molecules/Modal/Modal.vue';
import { useAuth } from '@/composables/useAuth';
import { useUser } from '@/composables/useUser';

大規模変換の工夫ポイント

  1. path alias の解決: @/components → 実際のファイルパスに変換
  2. Vue SFC 対応: .vueファイルの<script>セクションも解析
  3. 型のみの import: import type { ... } も正しく処理
  4. dry-run モード: 実際に変換する前にプレビュー可能

この変換スクリプトは、npm パッケージとしても公開しています:

https://github.com/sugitata/barrel-unbundler

# インストール
npm install -g barrel-unbundler

# 実行(dry-run)
barrel-unbundler --dry-run

# 実行(変換)
barrel-unbundler

ESLint ルールで再発防止

変換後、新たにバレルファイルが作られないように、ESLint ルールを追加しました。

https://github.com/thepassle/eslint-plugin-barrel-files

// eslint.config.js
import barrelFiles from 'eslint-plugin-barrel-files';

export default [
  {
    plugins: {
      'barrel-files': barrelFiles,
    },
    rules: {
      'barrel-files/avoid-barrel-files': 'error',
      'barrel-files/avoid-re-export-all': 'error',
      'barrel-files/avoid-importing-barrel-files': 'error',
    },
  },
];

結果

バレルファイルを排除した結果...

🎉 TSServer のクラッシュが完全に解消されました!

  • 「The JS/TS language service crashed」のダイアログは一度も表示されなくなった
  • 参照ジャンプが即座に動作するように
  • IDE の体感速度が劇的に向上

ついに勝利!

長い戦いでしたが、最も根ぞこに潜んでいた真犯人はバレルファイルでした。


終章:学びとまとめ

4 つの改善のポイント

# 改善内容 効果 TSServer クラッシュ
1 循環参照を潰す アーキテクチャ健全化 まだ発生
2 計測してボトルネック特定 問題の可視化 まだ発生
3 重い型推論の最適化 vue-tsc 高速化 まだ発生
4 バレルファイル排除 TSServer 安定化 勝利!

TypeScript パフォーマンス改善チェックリスト

自分のプロジェクトでも試してみたい方は、以下のチェックリストを参考にしてください:

  • 循環参照がないか確認(共有ライブラリ → 具象への依存)
  • vue-tsc --generateTrace でボトルネックを計測
  • バレルファイル(index.ts 経由の export)を排除
  • 重い型推論(ユニオン型の爆発、条件型のネスト)を見直し
  • type より interface を優先(拡張が必要な場合)
  • ESLint で再発防止ルールを設定

最も重要な学び

「便利さ」と「パフォーマンス」はトレードオフになることがある

バレルファイルは開発者にとって便利ですが、TSServer にとっては負荷の原因であり、循環参照の温床でした。

大規模プロジェクトでは、TypeScript に優しい書き方を意識することが重要です。

参考リソースまとめ


おわりに

TSServer のクラッシュに悩まされている方、参照ジャンプが遅くてイライラしている方の参考になれば幸いです。

特にバレルファイルの問題は、多くのプロジェクトで見過ごされがちです。もしあなたのプロジェクトでも TSServer が不安定な場合は、まずは計測を行い、その後、循環参照やバレルファイルを疑ってみてください。

Discussion