📦

TSにおけるモノレポ構成の最低限の設定と最適化のための技術を理解する

に公開

この記事は レバテック開発部 Advent Calendar 2025 14日目の記事です。

はじめに

こんにちは!
レバテック開発部の高瀬です。

今年の4月ごろに新規プロジェクトで開発したシステムでモノレポ構成を採用しています。
フロントエンド・バックエンドともに TypeScript で統一しており、pnpm の workspace 機能で管理しています。とりあえず動くようにした状態で開発スタートし、半年ほど運用してきましたが、正直なところ「どの設定で成り立っているのかよく分かっていない」状態でした。

今回、改めてモノレポ構成を実現するために最低限何が必要なのか、そしてどうすれば快適になるのかを整理してみました。

モノレポとは

モノレポ(monorepo) は、複数のパッケージやプロジェクトを単一のリポジトリで管理する構成です。mono(単一)+ repo(リポジトリ)が語源で、Google、Meta、Microsoft などが採用しています。

今回モノレポの定義を調べたところ、記事によって説明が違う印象で、モノリスやモジュラーモノリスといった用語も出てきて混乱したので整理しました。

  • モノレポ = リポジトリ構成の話
  • モノリス / マイクロサービス / モジュラーモノリス = アーキテクチャの話

観点が違うので、組み合わせ次第でこうなります。

リポジトリ構成 アーキテクチャ
モノレポ モノリス 1リポジトリに全部入り、デプロイも1つ
モノレポ マイクロサービス 1リポジトリで複数サービスを管理、個別デプロイ
モノレポ モジュラーモノリス 1リポジトリ、モジュール分割、デプロイは1つ
ポリレポ マイクロサービス サービスごとにリポジトリを分ける

混同しやすいので注意ですね。

Google のモノレポはどのくらい大きい?

Google は世界最大級のモノレポを運用しています。

  • 20億行以上のコード
  • 86TB のストレージ
  • 900万のソースファイル
  • 1日4万回のコミット(うち24,000回は自動化)
  • 25,000人以上の開発者が利用

Git では到底扱えない規模なので、Piper という独自のバージョン管理システムを開発して使っています。ちなみに Chrome と Android は別リポジトリで、モノレポに入っているのは全体の95%だそうです。
この規模になるとあんまり想像がつきませんね🫠

参考:Why Google Stores Billions of Lines of Code in a Single Repository

TSでモノレポ構成を実現するための設定

モノレポで複数のパッケージを管理するには、3つの層で設定が必要です。

1. パッケージの境界を定義する (workspace)
「どのディレクトリが1つのパッケージなのか」を定義し、node 実行時にパッケージ間のパスを解決できるようにします。

2. TypeScript にパッケージ間の関係を伝える (references)
「どのパッケージがどのパッケージに依存しているか」を TypeScript に伝え、ビルド順序の管理や型チェックを可能にします。

3. 開発体験を向上させる (paths)
import 時に相対パス(../../../)ではなくエイリアスを使えるようにし、エディタ補完とコードジャンプを快適にします。

それでは順番に見ていきましょう。

workspace の役割

パッケージ間の依存関係を宣言し、実行時にパスを解決できるようにする設定です。

pnpm の場合

# pnpm-workspace.yaml
packages:
  - "packages/*"
// packages/app/package.json
{
  "dependencies": {
    "@myapp/shared": "workspace:*"
  }
}

npm(v7+)/ yarn classic の場合

// ルートの package.json
{
  "workspaces": [
    "packages/*"
  ]
}
// 依存の書き方
"@myapp/shared": "*"

workspace を設定すると、パッケージマネージャが node_modules にシンボリックリンクを作成し、node で実行したときに @myapp/shared のようなパッケージ名でパスが解決できるようになります。また package.json に依存関係が明示されるため、将来 npm に公開したり別リポジトリに切り出したりすることも容易になります。

なぜ pnpm だけ workspace 設定を別ファイルにしているの?

npm と yarn は package.jsonworkspaces フィールドで workspace を定義しますが、pnpm は pnpm-workspace.yaml という専用ファイルを使います。なんで pnpm だけ分かれてるんだろう?と気になって調べてみました。

実は pnpm コミュニティでも「package.json に統一すべきでは?」という議論があったみたいです。他のツールとの互換性や、設定ファイルが増えることへの懸念があったようですね。

ただ、pnpm は専用ファイルを維持する方針を取っています。workspace 以外にも Catalogs(依存バージョンの一元管理)などの機能が pnpm-workspace.yaml に追加されていて、package.json に詰め込むと複雑になりすぎるためです。また、専用ファイルがあることで「このプロジェクトは pnpm で管理されている」ことが明確になるメリットもあります。

参考: Deprecate pnpm-workspace.yaml · pnpm · Discussion #3205


references の役割

パッケージ間の境界を TypeScript に伝える設定です。

今回は以下のような構成を例に説明します:

monorepo/
├── tsconfig.json      # ルート
└── packages/
    ├── shared/        # 共通ライブラリ(参照される側)
    └── app/          # アプリケーション(参照する側)

参照される側のパッケージ(packages/shared)には composite: true を設定します:

{
  "compilerOptions": {
    "composite": true,    // references で参照されるために必須
    "declaration": true   // composite: true で自動的に有効化される
  }
}

参照する側のパッケージ(packages/app)には依存するパッケージへの references を設定します:

{
  "compilerOptions": {
    "composite": true,
    "declaration": true
  },
  "references": [
    { "path": "../shared" }  // 依存するパッケージを指定
  ]
}

ルートtsconfig.json には全パッケージをリストします。これによりルートから tsc --build で全体を一括ビルドできます:

{
  "files": [],
  "references": [
    { "path": "packages/shared" },
    { "path": "packages/app" }
  ]
}

references を設定することで、別パッケージのソースコードを参照してもエラーにならなくなり(別プロジェクト扱い)、tsc --build で依存順に一括ビルドできるようになります。また incremental ビルドが効くため変更があったパッケージだけ再ビルドされ、循環参照も検出できます。


paths の役割

以下のようにtsconfig.jsonで設定します。

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@myapp/shared": ["../shared/src/index.ts"]
    }
  }
}

これにより、相対パスではなくエイリアスで import できるようになります。

// 相対パス(階層が深いと辛い)
import { greet } from "../../../shared/src/index";
// エイリアス(どこからでも同じ書き方)
import { greet } from "@myapp/shared";

これでエディタ上の補完と型解決が効くようになります。

ただし paths だけだと問題があります。ビルド後の JS を node で実行すると Cannot find module '@myapp/shared' というエラーになってしまいます。これは paths が TypeScript の型解決とエディタ補完には効く一方で、tsc が出力する JS の import パスを書き換えないためです。


動作するサンプル全体

コピペで動くサンプルです。

ディレクトリ構成

monorepo-sample/
├── package.json
├── tsconfig.json
├── pnpm-workspace.yaml
└── packages/
    ├── shared/
    │   ├── package.json
    │   ├── tsconfig.json
    │   └── src/
    │       └── index.ts
    └── app/
        ├── package.json
        ├── tsconfig.json
        └── src/
            └── index.ts
package.json(ルート)
{
  "name": "monorepo-sample",
  "private": true,
  "scripts": {
    "build": "tsc --build"
  },
  "devDependencies": {
    "typescript": "^5.9.0"
  }
}
tsconfig.json(ルート)
{
  "files": [],
  "references": [
    { "path": "packages/shared" },
    { "path": "packages/app" }
  ]
}
pnpm-workspace.yaml
packages:
  - "packages/*"
packages/shared/package.json
{
  "name": "@myapp/shared",
  "version": "1.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts"
}
packages/shared/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "rootDir": "src",
    "outDir": "dist"
  },
  "include": ["src/**/*"]
}
packages/shared/src/index.ts
export const greet = (name: string): string => {
  return `Hello, ${name}!`;
};
packages/app/package.json
{
  "name": "@myapp/app",
  "version": "1.0.0",
  "main": "dist/index.js",
  "scripts": {
    "start": "node dist/index.js"
  },
  "dependencies": {
    "@myapp/shared": "workspace:*"
  }
}
packages/app/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "rootDir": "src",
    "outDir": "dist",
    "baseUrl": ".",
    "paths": {
      "@myapp/shared": ["../shared/src/index.ts"]
    }
  },
  "include": ["src/**/*"],
  "references": [
    { "path": "../shared" }
  ]
}
packages/app/src/index.ts
import { greet } from "@myapp/shared";

console.log(greet("World"));

動作確認

pnpm install
pnpm build
node packages/app/dist/index.js
# Hello, World!

appからsharedの関数を呼び出せています!

モノレポ構成を快適にするためにできること

ここまでの設定で基本的なモノレポは動きますが、実用的に運用するにはいくつかの課題があります:

  • ビルド時間 - パッケージが増えると、変更のないパッケージも毎回ビルドされる
  • 設定の重複 - 各パッケージで同じ tsconfig を書くのが面倒
  • 開発体験 - ビルドの差分検知やコードジャンプの精度を上げたい

今回は導入しやすく効果の高いものを紹介します。

ビルドキャッシュ(Turborepo)

パッケージ数が増えてくると、変更していないパッケージも毎回ビルドされるため時間がかかります。Turborepo を使うと、変更があったパッケージだけを再ビルドできます(Nx も同様の機能がありますが、今回は軽量な Turborepo を紹介します)。

インストール

pnpm add -D turbo -w

turbo.json(ルート)

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    }
  }
}

package.json(ルート)を修正

{
  "scripts": {
    "build": "turbo run build"
  }
}

各パッケージにビルドスクリプトを追加

// packages/shared/package.json
{
  "scripts": {
    "build": "tsc"
  }
}
// packages/app/package.json
{
  "scripts": {
    "build": "tsc"
  }
}

動作確認

pnpm build

2回目以降は、変更がないパッケージはキャッシュが使われてスキップされます。ローカルにキャッシュが残るので差分ビルドが効くのと、Remote Caching を使えば CI でもキャッシュを共有できます。


tsconfig の最適化

TypeScript のビルド設定を最適化することで、ビルド時間の短縮やエディタの快適性が向上します。

composite と incremental

{
  "compilerOptions": {
    "composite": true
  }
}
  • composite: true - project references を使う場合に必須。設定すると declarationincremental が自動で有効になり、差分ビルドが効くようになります
  • incremental: true - 単独プロジェクトで差分ビルドしたい場合に個別に設定します。モノレポで references を使う場合は composite で自動的に有効になるため、明示的に書く必要はありません

コードジャンプの確実性を高める

{
  "compilerOptions": {
    "declarationMap": true
  }
}

declarationMap を有効にすると、エディタのコードジャンプが確実に元の .ts ファイルに飛ぶようになります。paths と組み合わせることで、どの経路からアクセスしても快適な開発体験が得られます。

ビルドの高速化

{
  "compilerOptions": {
    "skipLibCheck": true
  }
}

skipLibCheck を有効にすると、node_modules 内の型定義ファイルの型チェックをスキップしてビルドが高速化されます。


共通設定の切り出し

パッケージごとに同じ設定を書くのは面倒なので、共通部分を tsconfig.base.json に切り出します。

ディレクトリ構成

monorepo-sample/
├── tsconfig.base.json  # 共通設定
├── tsconfig.json       # ルート(references のみ)
└── packages/
    ├── shared/
    │   └── tsconfig.json  # extends で継承
    └── app/
        └── tsconfig.json  # extends で継承

tsconfig.base.json(共通設定)

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "composite": true,
    "declarationMap": true
  }
}

packages/shared/tsconfig.json

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist"
  },
  "include": ["src/**/*"]
}

packages/app/tsconfig.json

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist",
    "baseUrl": ".",
    "paths": {
      "@myapp/shared": ["../shared/src/index.ts"]
    }
  },
  "include": ["src/**/*"],
  "references": [
    { "path": "../shared" }
  ]
}

extends で共通設定を継承して、パッケージ固有の設定だけを上書きします。設定を変更したいときは tsconfig.base.json を修正するだけで全パッケージに反映されます。


さらなる最適化

今回紹介しきれなかったものの、モノレポの規模が大きくなってきたら検討したい最適化です。

課題 ツール できること
ビルドが遅い tsup / esbuild / SWC tsc より高速なビルド
CI が遅い Turborepo / Nx の affected 変更箇所だけテスト・デプロイ
依存バージョンがバラバラ syncpack バージョンの統一
未使用コードが残る Knip 未使用ファイル・依存の検出
リリース管理が大変 changesets バージョン管理・CHANGELOG 自動生成
コード品質を揃えたい ESLint / Prettier / Biome 共通設定の継承
依存関係が複雑 Nx Graph 依存グラフの可視化

規模に応じて、必要なものから取り入れていくと良さそうです。

まとめ

TypeScript でモノレポを構築するには、workspace(実行時のパス解決)references(ビルド順序と型)paths(エディタ補完) の3つが必要で、どれか欠けると問題が出ます。快適にするなら Turborepotsconfig.base.jsondeclarationMap あたりが手軽で導入しやすそうです!

私のプロジェクトではまだ workspace機能でモノレポ構成を実現することに留まっていますが、今回の整理で「なんとなく動いてる」からは卒業できた気がします。次は Turborepo あたりから導入していきたいですね。

参考記事

レバテック開発部

Discussion