TypeScript Monorepo実践:Turborepo設定からCI/CD最適化まで6つのキー戦略
前端工程
Monorepoの痛み:使った人だけが知っている
5つのパッケージ、3つのアプリ、2つのCI設定——ユーティリティ関数を変更するたびに、npmパッケージを手動公開、依存バージョンを更新、3つのアプリを再デプロイしなければならない。Lernaはメンテナンス停止、Nxは学習曲線が急、Rushは設定が煩雑。2026年、Turborepo + pnpm workspaceがついにTypeScript Monorepoを「動く」から「使いやすい」へ——インクリメンタルビルド、リモートキャッシュ、パイプラインオーケストレーション、1コマンドで完了。
本記事では6つのキー戦略から出発し、プロジェクト初期化→タスクオーケストレーション→リモートキャッシュ→CI/CD最適化→パッケージ公開→モニタリングのフルパイプラインを実践します。
TypeScript Monorepoコア概念
| 概念 | 説明 |
|---|---|
| Monorepo | 単一コードリポジトリで複数パッケージ/アプリを管理するエンジニアリングパターン |
| pnpm workspace | pnpmネイティブMonorepoサポート、pnpm-workspace.yamlでパッケージディレクトリを宣言 |
| Turborepo | Vercel製の高性能Monorepoビルドシステム、コアはタスクパイプラインとキャッシュ |
| タスクパイプライン | タスク間の依存関係と実行順序を定義、例:buildは^buildに依存 |
| リモートキャッシュ | ビルドキャッシュをリモートにアップロード、チームメンバーとCIで共有 |
| インクリメンタルビルド | 変更されたパッケージとその依存先のみ再ビルド、未変更パッケージをスキップ |
| Changeset | パッケージバージョンとchangelogを管理するツール、マルチパッケージ協調公開をサポート |
| Workspace Protocol | pnpmのworkspace:*プロトコル、パッケージ間参照は常にローカルソースを指す |
問題分析:Monorepoの5つの課題
- 依存地獄:パッケージAがB@1.0に依存、パッケージCがB@2.0に依存、バージョン競合の解決が困難
- ビルドパフォーマンス:フルビルドが分単位から時間単位、CIコスト急増
- CI/CD設定:各パッケージに個別パイプライン設定、メンテナンスコスト高で不整合しやすい
- パッケージ公開フロー:マルチパッケージバージョン管理、changelog生成、公開順序の調整
- 開発体験:TypeScriptプロジェクト参照の設定が複雑、IDEナビゲーションと型チェックが遅い
ステップバイステップ:6つのキー戦略
戦略1:プロジェクト初期化とpnpmワークスペース
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
{
"name": "my-monorepo",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test",
"clean": "turbo run clean"
},
"devDependencies": {
"turbo": "^2.3.0",
"typescript": "^5.7.0"
},
"packageManager": "pnpm@9.15.0"
}
// apps/web/package.json
{
"name": "@my-org/web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"lint": "eslint . --ext .ts,.tsx",
"test": "vitest run"
},
"dependencies": {
"@my-org/ui": "workspace:*",
"@my-org/utils": "workspace:*",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"typescript": "^5.7.0"
}
}
// packages/ui/package.json
{
"name": "@my-org/ui",
"version": "2.1.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts",
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
"lint": "eslint src/",
"test": "vitest run",
"clean": "rm -rf dist"
},
"peerDependencies": {
"react": "^19.0.0"
},
"devDependencies": {
"tsup": "^8.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}
戦略2:Turborepoタスクパイプライン設定
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"globalEnv": ["NODE_ENV"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
"outputMode": "new-only"
},
"dev": {
"cache": false,
"persistent": true,
"dependsOn": ["^build"]
},
"lint": {
"dependsOn": ["^build"],
"outputs": []
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"],
"inputs": ["src/**/*.ts", "test/**/*.ts"]
},
"clean": {
"cache": false
},
"typecheck": {
"dependsOn": ["^build"],
"outputs": []
}
}
}
// packages/utils/src/index.ts
export function formatDate(date: Date, locale: string = 'ja-JP'): string {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
export function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
export function deepClone<T>(obj: T): T {
return structuredClone(obj);
}
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
export function tryCatch<T, E = Error>(fn: () => T): Result<T, E> {
try {
return { ok: true, value: fn() };
} catch (error) {
return { ok: false, error: error as E };
}
}
戦略3:リモートキャッシュ設定
# docker-compose.yml - セルフホストTurborepo Remote Cache
version: '3.8'
services:
turbo-cache:
image: ghcr.io/ducktors/turborepo-remote-cache:latest
ports:
- "3001:3000"
environment:
- STORAGE_TYPE=s3
- S3_BUCKET=turbo-cache
- S3_REGION=us-east-1
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
restart: unless-stopped
# CIでリモートキャッシュを使用
export TURBO_API="https://cache.my-company.com"
export TURBO_TOKEN="${{ secrets.TURBO_TOKEN }}"
export TURBO_TEAM="my-team"
pnpm turbo run build --remote-cache-only
戦略4:CI/CDパイプライン最適化
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
TURBO_API: ${{ vars.TURBO_API }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
lint-typecheck:
name: Lint & TypeCheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run lint typecheck --concurrency=4
test:
name: Test
runs-on: ubuntu-latest
needs: lint-typecheck
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run test --concurrency=2
build:
name: Build
runs-on: ubuntu-latest
needs: [lint-typecheck, test]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run build --concurrency=3
戦略5:Changesetマルチパッケージ公開
// .changeset/config.json
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [["@my-org/ui", "@my-org/utils"]],
"linked": [["@my-org/ui", "@my-org/utils"]],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@my-org/web", "@my-org/docs"]
}
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run build --filter=./packages/*
- name: Create Release Pull Request or Publish
uses: changesets/action@v1
with:
publish: pnpm changeset publish
version: pnpm changeset version
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
戦略6:TypeScriptプロジェクト参照とインクリメンタルコンパイル
// tsconfig.base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true,
"incremental": true
}
}
// packages/utils/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
// apps/web/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "preserve",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"noEmit": true,
"incremental": true,
"paths": {
"@my-org/ui": ["../../packages/ui/src"],
"@my-org/utils": ["../../packages/utils/src"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"],
"references": [
{ "path": "../../packages/ui" },
{ "path": "../../packages/utils" }
]
}
落とし穴ガイド
落とし穴1:workspace:*が公開時に置換されない
// ❌ 誤り:workspace:*がそのままnpmに公開される
{
"dependencies": {
"@my-org/utils": "workspace:*"
}
}
// ✅ 正しい:pnpm publishが自動置換、pnpmバージョン>=8を確認
// pnpmはworkspace:*を実際のバージョン番号に自動置換
落とし穴2:turbo.jsonに^build依存が欠落
// ❌ 誤り:buildが上流パッケージのbuildに依存しない
{
"tasks": {
"build": {
"dependsOn": ["build"]
}
}
}
// ✅ 正しい:^buildは依存パッケージのbuild完了を待つ
{
"tasks": {
"build": {
"dependsOn": ["^build"]
}
}
}
落とし穴3:CIでキャッシュヒット未設定
# ❌ 誤り:毎回CIでフルビルド
- run: pnpm turbo run build
# ✅ 正しい:リモートキャッシュ + フリーズロックファイル
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run build
env:
TURBO_API: ${{ vars.TURBO_API }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
落とし穴4:tsupがdtsファイルを生成しない
// ❌ 誤り:JSのみ出力、型宣言なし
{
"scripts": {
"build": "tsup src/index.ts --format cjs,esm"
}
}
// ✅ 正しい:--dtsを追加して.d.tsファイルを生成
{
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts"
}
}
落とし穴5:Changesetがプライベートパッケージを無視しない
// ❌ 誤り:プライベートアプリパッケージもchangeset公開に参加
{
"ignore": []
}
// ✅ 正しい:プライベートアプリパッケージはnpm公開不要、無視すべき
{
"ignore": ["@my-org/web", "@my-org/docs", "@my-org/admin"]
}
エラートラブルシューティング
| # | エラーメッセージ | 原因 | 解決方法 |
|---|---|---|---|
| 1 | ERR_PNPM_PEER_DEP_ISSUES |
peer依存バージョン競合 | package.jsonでpnpm.peerDependencyRules.allowedVersionsを設定 |
| 2 | turbo: no tasks found |
turbo.json未設定またはパッケージにscriptなし | turbo.jsonの存在とパッケージのscriptを確認 |
| 3 | ERR_PNPM_WORKSPACE_PKG_NOT_FOUND |
workspace:*参照先パッケージが存在しない | pnpm-workspace.yamlとパッケージのnameフィールドを確認 |
| 4 | Type error: Cannot find module '@my-org/utils' |
TypeScriptパスマッピング未設定 | tsconfig.jsonでpathsとreferencesを設定 |
| 5 | turbo cache miss |
キャッシュキー変更またはリモートキャッシュ未設定 | inputs/outputs設定を確認、TURBO_APIを設定 |
| 6 | npm ERR! 403 Forbidden |
npm公開権限不足 | NPM_TOKENが有効でパッケージスコープが設定されていることを確認 |
| 7 | Changeset: No changeset files found |
changesetファイル未作成 | pnpm changesetを実行して変更説明を作成 |
| 8 | ERR_PNPM_LOCKFILE_MISSING |
pnpm-lock.yamlが不在 | pnpm installを実行してロックファイルを生成 |
| 9 | turbo: task "build" failed in "@my-org/ui" |
依存パッケージのビルド失敗 | 失敗パッケージを個別ビルド:pnpm turbo run build --filter=@my-org/ui |
| 10 | ESLint: Cannot read config file |
monorepo内でESLint設定パスが誤り | @my-org/eslint-configで統一設定 |
高度な最適化
1. Turborepoフィルター精密実行
# 変更されたパッケージとその依存先のみビルド
pnpm turbo run build --filter=...[HEAD^1]
# 特定パッケージのみビルド
pnpm turbo run build --filter=@my-org/ui
# 指定パッケージを除くすべてをビルド
pnpm turbo run build --filter=!@my-org/web
# @my-org/utilsに依存するすべてのパッケージをビルド
pnpm turbo run build --filter=...@my-org/utils
# 組み合わせフィルター:apps下の変更パッケージをビルド
pnpm turbo run build --filter=./apps/*[HEAD^1]
2. 共有ESLintとTypeScript設定
// packages/eslint-config/index.js
module.exports = {
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
],
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
overrides: [
{
files: ['**/*.tsx'],
extends: ['plugin:react/recommended', 'plugin:react-hooks/recommended'],
settings: {
react: { version: 'detect' },
},
},
],
};
3. Monorepo健全性モニタリング
// scripts/monorepo-health.ts
import { execSync } from 'child_process';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
interface PackageHealth {
name: string;
hasReadme: boolean;
hasTests: boolean;
hasBuildScript: boolean;
outdatedDeps: number;
typeCheckPass: boolean;
}
function checkMonorepoHealth(): PackageHealth[] {
const workspaces = execSync('pnpm ls -r --depth 0 --json', { encoding: 'utf-8' });
const packages = JSON.parse(workspaces);
return packages.map((pkg: any) => {
const pkgDir = pkg.path;
const pkgJson = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf-8'));
return {
name: pkg.name,
hasReadme: existsSync(join(pkgDir, 'README.md')),
hasTests: existsSync(join(pkgDir, 'test')) || existsSync(join(pkgDir, '__tests__')),
hasBuildScript: 'build' in (pkgJson.scripts || {}),
outdatedDeps: 0,
typeCheckPass: true,
};
});
}
const health = checkMonorepoHealth();
console.table(health);
比較分析
| 次元 | Turborepo + pnpm | Nx | Lerna | Rush | pnpmのみ |
|---|---|---|---|---|---|
| 学習曲線 | ⭐低 | ⭐⭐⭐高 | ⭐⭐中 | ⭐⭐⭐高 | ⭐低 |
| インクリメンタルビルド | ✅内蔵 | ✅内蔵 | ❌設定必要 | ✅内蔵 | ❌ |
| リモートキャッシュ | ✅Vercel/セルフホスト | ✅Nx Cloud | ❌ | ✅BuildCache | ❌ |
| タスクオーケストレーション | ✅Pipeline | ✅非常に強力 | ⚠️基本 | ✅ | ❌ |
| パッケージ公開 | ⚠️Changeset必要 | ✅内蔵 | ✅内蔵 | ✅内蔵 | ❌ |
| 依存管理 | ✅pnpm | ✅pnpm/npm/yarn | ⚠️npm/yarn | ✅pnpm | ✅pnpm |
| メンテナンス状況 | ✅アクティブ | ✅アクティブ | ❌メンテナンス停止 | ✅アクティブ | ✅アクティブ |
| エコシステム統合 | ⭐Next.js優先 | ⭐Angular優先 | ⭐汎用 | ⭐汎用 | ⭐汎用 |
まとめ:Turborepoは「また一つのMonorepoツール」ではなく「ビルドキャッシュの革命」です。コアバリューはタスクオーケストレーション(pnpm -rでも可能)ではなく、インテリジェントキャッシュにあります——ローカルキャッシュで開発者の重複ビルドを回避、リモートキャッシュでCIの重複ビルドを回避、
--filterでビルドすべきものだけをビルド。2026年のMonorepo選定:小規模チームはTurborepo + pnpm(ゼロ設定で即利用)、大規模チームはNx(より強力なコード生成と依存グラフ分析)、Lernaは使用しない(メンテナンス停止)。
オンラインツール推奨
- JSONフォーマッター:/ja/json/format
- Base64エンコード/デコード:/ja/encode/base64
- Hash計算:/ja/encode/hash
- JWTデコード:/ja/encode/jwt-decode
ブラウザローカルツールを無料で試す →
#TypeScript#Monorepo#Turborepo#pnpm#CI/CD#2026#前端工程化