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つの課題

  1. 依存地獄:パッケージAがB@1.0に依存、パッケージCがB@2.0に依存、バージョン競合の解決が困難
  2. ビルドパフォーマンス:フルビルドが分単位から時間単位、CIコスト急増
  3. CI/CD設定:各パッケージに個別パイプライン設定、メンテナンスコスト高で不整合しやすい
  4. パッケージ公開フロー:マルチパッケージバージョン管理、changelog生成、公開順序の調整
  5. 開発体験: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は使用しない(メンテナンス停止)。


オンラインツール推奨

ブラウザローカルツールを無料で試す →

#TypeScript#Monorepo#Turborepo#pnpm#CI/CD#2026#前端工程化