TypeScript の monorepo を考え始めると、すぐに選択肢が増える。pnpm、bun、Turborepo、Nx、Changesets、Biome。どれも正しそうに見えるが、全部を最初から入れると重い。逆に全部切ると、あとで困る。
この手の話がまとまりにくいのは、前提を切らずに「最適解」だけを探してしまうからだ。汎用の無難解と、いま目の前にある構成での実務解は分けて考えた方がいい。
今回は次の 2 つを分けて整理する。
- これから TypeScript monorepo を新規に作るときの無難な構成
apps/web、apps/wxt、packages/ui、packages/coreという具体前提での現実的な構成
まず結論
| 前提 | まず選ぶ構成 | 補足 |
|---|---|---|
| 汎用の新規 monorepo。将来的に package 公開、CI 最適化、Docker 連携も視野に入る | pnpm workspace + Turborepo + TypeScript Project References + package.json の exports + Changesets | 一番ぶれにくい |
apps/web、apps/wxt、packages/ui、packages/core の 4 パッケージ構成。Docker は使わない。共有 package は npm 公開しない | pnpm workspace + Biome + TypeScript Project References | この段階では Turborepo はまだ不要 |
| 実行環境、package manager、テストまで Bun に寄せ切る | bun workspace も候補 | ただし運用の無難さでは pnpm が強い |
要するに、汎用論では pnpm + turbo が強い。でも今回の前提では workspace only で十分、という整理になる。
汎用の無難解
2026 年時点で、深く悩まずに始めるなら土台は pnpm workspace が一番安定している。workspace を標準で持っていて、workspace: protocol でローカル package 参照を明示できるし、--filter で対象 package をかなり細かく絞れる。
型境界には TypeScript Project References を使う。TypeScript 公式ドキュメントでも、分割された project によって build time の改善、論理的な分離、tsc --build による依存順ビルドができると明記されている。monorepo ではここがかなり効く。
その上で Turborepo を載せると、task graph、キャッシュ、並列実行、turbo prune まで一気に揃う。Turborepo の task 設定 は dependsOn と outputs を中心に組み立てるだけでよく、repo が大きくなったときの伸びしろがある。
公開する package を持つなら Changesets も自然に入る。pnpm の workspace ドキュメント でも、workspace 内 package の versioning には Changesets などの専用ツールを使う前提になっている。
つまり、汎用の無難解はこうなる。
| 役割 | 推奨 |
|---|---|
| workspace 管理 | pnpm workspace |
| 型境界と増分ビルド | TypeScript Project References |
| タスク実行、並列化、キャッシュ | Turborepo |
| package 境界の公開面 | package.json の exports |
| versioning / changelog | Changesets |
| lint / format | Biome か ESLint + Prettier。今なら Biome で始める方が軽い |
今回の前提ではなぜ Turborepo がまだ要らないか
今回の構成はかなり単純だ。
packages/corepackages/ui -> packages/coreapps/web -> packages/ui, packages/coreapps/wxt -> packages/ui, packages/core依存関係がこの程度なら、pnpm workspace の filter 実行と tsc -b だけでも運用は十分回る。
Turborepo を入れる価値が一気に上がるのは、たとえばこういうときだ。
| 痛み | Turborepo が効く理由 |
|---|---|
| build / test / lint の総時間が長い | キャッシュと並列実行が効く |
| task の依存順を明示したい | dependsOn で graph を表現できる |
| CI を速くしたい | remote cache が効く |
| Docker 用に必要 package だけ切り出したい | turbo prune が使える |
逆に、まだそこまで困っていない段階で入れると、設定ファイルが 1 枚増える以上の価値を感じにくい。Docker を切るなら prune の魅力も一段落ちる。共有 package を npm 公開しないなら Changesets も必須ではない。
だから、この前提ならまずは pnpm workspace + Biome + TypeScript Project References で始めるのが妥当だ。
レイヤーの切り方
この 4 分割では、役割を曖昧にしないことの方がツール選定より重要だ。
| パス | 置くもの | 置かない方がいいもの |
|---|---|---|
packages/core | domain、schema、validation、API client、pure function、state model | UI、routing、browser API |
packages/ui | 環境依存しない React component / hook | WXT 固有処理、router、extension storage |
apps/web | Web アプリとしての組み立て、page、routing、adapter | extension 固有事情 |
apps/wxt | background / content script、browser API、WXT 固有設定 | Web 専用 routing、共有層に置ける pure logic |
いちばん避けたいのは、packages/ui を「共有っぽいもの全部置き場」にすることだ。ここに browser extension 固有事情や routing が入り始めると、共有層ではなく結合点になる。そうなると monorepo の恩恵が薄れる。
apps/wxt の注意点
apps/wxt だけは少し気をつけた方がいい。WXT の TypeScript 設定 では、通常は root の tsconfig.json から .wxt/tsconfig.json を extend する。ただし monorepo ではその形を取らないこともあり、その場合は .wxt/wxt.d.ts を TypeScript project に含める必要がある。
つまり、apps/wxt は monorepo の一部ではあるが、TypeScript project としては独立気味に扱った方が安定する。
tsconfig.paths を越境参照の主手段にしない
ここはかなり大事だ。
TypeScript 5.7 の release notes では、baseUrl や paths に依存した import は JS 出力時に書き換えられないと明記されている。つまり、tsconfig.paths だけで package 境界をまたぐ設計は、型チェックでは通っても実行系や publish の段階で歪みやすい。
monorepo の越境参照は、基本的に次の順で考えた方がいい。
- package を分ける
workspace:で依存関係を結ぶpackage.jsonのexportsで公開面を決めるTypeScript Project Referencesで依存順ビルドを作る
paths は package 内の補助に留める方が壊れにくい。
Biome はこの前提と相性がいい
ESLint を外して Biome に寄せる判断はかなり自然だと思う。Biome の big projects ガイド でも、monorepo や workspace のような大きい repo 向けに root config と nested config を使い分ける前提が整理されている。v2 では monorepo を前提にした構成も案内されている。
この規模なら、lint / format を Biome に寄せて、project boundary と build だけを TypeScript 側で面倒を見る方が素直だ。
最小構成のイメージ
最初のセットアップはこのくらいで十分だ。
- root に
pnpm-workspace.yamlを置く - root に solution-style の
tsconfig.jsonを置いて各 package をreferencesする packages/coreとpackages/uiはcompositeを有効にする- 共有 package は
package.jsonのexportsを明示する - root に
biome.jsonを置く apps/wxtは WXT の型定義の扱いだけ別途確認する
ここまでで、かなり長く戦える。
いつ次の道具を足すか
| 追加するもの | 足すタイミング |
|---|---|
Turborepo | build / test / lint が重い、CI 最適化が必要、task graph を明示したい |
Changesets | 共有 package を version 管理して公開したい |
Nx | 自動生成、polyglot、より強い monorepo 管理まで欲しくなった |
bun workspace への移行 | runtime / package manager / test を Bun に統一したくなった |
monorepo は、最初から全部入りにするより、痛みが出たところだけ足す方がうまくいく。
まとめ
TypeScript monorepo の最適解は 1 つではない。汎用の無難解なら pnpm workspace + Turborepo + TypeScript Project References が強い。でも、apps/web、apps/wxt、packages/ui、packages/core という 4 分割で、Docker も package 公開もまだ重くないなら、pnpm workspace + Biome + TypeScript Project References で始める方が軽くて壊れにくい。
大事なのは、ツールを増やすことではなく、core と ui を薄く保ち、越境参照を workspace: と exports で素直に表現することだ。そこが崩れなければ、あとから turbo を足しても遅くない。
hsb.horse