TypeScriptモノレポ構築|pnpm Workspaces・Turborepoの実践ガイド

kento_morota 20分で読めます

複数のパッケージやアプリケーションを1つのリポジトリで管理する「モノレポ」は、コードの共有やバージョン管理を効率化する手法として広く採用されています。TypeScriptプロジェクトでは、pnpm WorkspacesとTurborepoの組み合わせが定番の選択肢です。

本記事では、モノレポの基本概念から、pnpm Workspaces + Turborepoによる環境構築、パッケージ間の依存管理、CI/CDの設定まで実践的に解説します。

モノレポとは何か・なぜ採用するのか

モノレポ(Monorepo)とは、複数のプロジェクト・パッケージを1つのGitリポジトリで管理するアーキテクチャです。Google、Meta、Microsoftなどの大規模企業でも採用されています。

モノレポのメリット

コード共有が容易:共通のユーティリティ、型定義、UIコンポーネントを複数アプリで簡単に再利用できます。npmパッケージとして公開する手間もありません。

一貫したツール設定:ESLint、Prettier、TypeScriptの設定を1箇所で管理でき、全プロジェクトに統一されたルールを適用できます。

アトミックな変更:共有パッケージの変更とそれを利用するアプリの修正を1つのコミット・PRでまとめられるため、不整合が起きにくくなります。

依存関係の可視化:パッケージ間の依存関係がworkspace内で明確に管理され、循環依存の検出も容易です。

pnpm + Turborepoを選ぶ理由

pnpmは、ハードリンクとシンボリックリンクを活用した高速なパッケージマネージャーです。ディスク容量を節約し、幽霊依存(phantom dependencies)を防止するstrict node_modulesが特徴です。

Turborepoは、モノレポのタスク実行を最適化するビルドシステムです。キャッシュ、並列実行、依存グラフに基づくタスクの順序制御を自動で行います。

プロジェクトの初期セットアップ

ディレクトリ構成

# プロジェクト初期化
mkdir my-monorepo && cd my-monorepo
pnpm init

# Turborepoのインストール
pnpm add -D turbo

# ディレクトリ作成
mkdir -p apps/web apps/api packages/ui packages/shared packages/config-typescript packages/config-eslint

完成後のディレクトリ構成は以下のようになります。

my-monorepo/
├── apps/
│   ├── web/              # Next.jsフロントエンド
│   └── api/              # Express.js APIサーバー
├── packages/
│   ├── ui/               # 共有UIコンポーネント
│   ├── shared/           # 共有型定義・ユーティリティ
│   ├── config-typescript/ # 共有TSConfig
│   └── config-eslint/    # 共有ESLint設定
├── pnpm-workspace.yaml
├── turbo.json
├── package.json
└── .gitignore

pnpm-workspace.yamlの設定

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"

ルートpackage.jsonの設定

{
  "name": "my-monorepo",
  "private": true,
  "scripts": {
    "dev": "turbo dev",
    "build": "turbo build",
    "lint": "turbo lint",
    "test": "turbo test",
    "clean": "turbo clean",
    "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\""
  },
  "devDependencies": {
    "turbo": "^2.4.0",
    "prettier": "^3.4.0"
  },
  "packageManager": "pnpm@9.15.0"
}

共有パッケージの作成

共有TypeScript設定(config-typescript)

// packages/config-typescript/package.json
{
  "name": "@repo/config-typescript",
  "private": true,
  "files": ["base.json", "nextjs.json", "node.json"]
}
// packages/config-typescript/base.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "incremental": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "exclude": ["node_modules", "dist"]
}
// packages/config-typescript/node.json
{
  "extends": "./base.json",
  "compilerOptions": {
    "module": "ESNext",
    "target": "ES2022",
    "lib": ["ES2022"],
    "outDir": "./dist"
  }
}
// packages/config-typescript/nextjs.json
{
  "extends": "./base.json",
  "compilerOptions": {
    "module": "ESNext",
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "ES2017"],
    "jsx": "preserve",
    "noEmit": true,
    "plugins": [{ "name": "next" }]
  }
}

共有型定義・ユーティリティ(shared)

// packages/shared/package.json
{
  "name": "@repo/shared",
  "private": true,
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch",
    "clean": "rm -rf dist"
  },
  "devDependencies": {
    "@repo/config-typescript": "workspace:*",
    "typescript": "^5.7.0"
  }
}
// packages/shared/src/index.ts
export * from "./types";
export * from "./utils";
export * from "./constants";
// packages/shared/src/types.ts
export interface User {
  id: number;
  name: string;
  email: string;
  role: UserRole;
  createdAt: string;
  updatedAt: string;
}

export type UserRole = "admin" | "editor" | "viewer";

export type UserCreateInput = Omit<User, "id" | "createdAt" | "updatedAt">;
export type UserUpdateInput = Partial<UserCreateInput>;

export interface ApiResponse<T> {
  success: boolean;
  data: T;
  message?: string;
}

export interface PaginatedResponse<T> extends ApiResponse<T[]> {
  pagination: {
    page: number;
    perPage: number;
    total: number;
    totalPages: number;
  };
}
// packages/shared/src/utils.ts
export function formatDate(date: string | Date): string {
  const d = typeof date === "string" ? new Date(date) : date;
  return d.toLocaleDateString("ja-JP", {
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
  });
}

export function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

export function chunk<T>(array: T[], size: number): T[][] {
  return Array.from({ length: Math.ceil(array.length / size) }, (_, i) =>
    array.slice(i * size, i * size + size)
  );
}

共有UIコンポーネント(ui)

// packages/ui/package.json
{
  "name": "@repo/ui",
  "private": true,
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch",
    "clean": "rm -rf dist"
  },
  "peerDependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@repo/config-typescript": "workspace:*",
    "react": "^19.0.0",
    "typescript": "^5.7.0"
  }
}
// packages/ui/src/Button.tsx
import React from "react";

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: "primary" | "secondary" | "danger";
  size?: "sm" | "md" | "lg";
  isLoading?: boolean;
}

export function Button({
  variant = "primary",
  size = "md",
  isLoading = false,
  children,
  disabled,
  ...props
}: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant} btn-${size}`}
      disabled={disabled || isLoading}
      {...props}
    >
      {isLoading ? "読み込み中..." : children}
    </button>
  );
}

Turborepoの設定とタスク管理

turbo.jsonの設定

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "dependsOn": ["^build"]
    },
    "test": {
      "dependsOn": ["^build"]
    },
    "clean": {
      "cache": false
    }
  }
}

dependsOn: ["^build"]は「依存しているパッケージのbuildが完了してから実行する」という意味です。sharedパッケージのbuildが完了してから、webやapiのbuildが実行されます。

フィルタリングによる部分実行

# 特定のアプリだけビルド
pnpm turbo build --filter=web

# 特定のパッケージとその依存先だけビルド
pnpm turbo build --filter=web...

# 変更されたパッケージだけビルド(CI向け)
pnpm turbo build --filter='...[HEAD^1]'

# 特定パッケージを除外
pnpm turbo build --filter='!@repo/ui'

キャッシュの仕組み

Turborepoは、各タスクの入力(ソースコード、依存関係、環境変数)をハッシュ化し、結果をキャッシュします。入力が変わっていなければビルドをスキップし、キャッシュされた成果物を復元します。

// turbo.json でキャッシュ対象の環境変数を指定
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"],
      "env": ["NODE_ENV", "API_URL"]
    }
  },
  "globalEnv": ["CI"]
}

アプリケーションの構築

Next.jsフロントエンド(apps/web)

// apps/web/package.json
{
  "name": "web",
  "private": true,
  "scripts": {
    "dev": "next dev --port 3000",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@repo/shared": "workspace:*",
    "@repo/ui": "workspace:*",
    "next": "^15.2.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@repo/config-typescript": "workspace:*",
    "typescript": "^5.7.0"
  }
}
// apps/web/src/app/page.tsx
import { Button } from "@repo/ui";
import { formatDate } from "@repo/shared";
import type { User } from "@repo/shared";

export default async function HomePage() {
  const res = await fetch(`${process.env.API_URL}/api/users`);
  const { data: users } = (await res.json()) as { data: User[] };

  return (
    <main>
      <h1>ユーザー一覧</h1>
      {users.map((user) => (
        <div key={user.id}>
          <p>{user.name} - {formatDate(user.createdAt)}</p>
          <Button variant="primary" size="sm">詳細</Button>
        </div>
      ))}
    </main>
  );
}

Express APIサーバー(apps/api)

// apps/api/package.json
{
  "name": "api",
  "private": true,
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js",
    "clean": "rm -rf dist"
  },
  "dependencies": {
    "@repo/shared": "workspace:*",
    "express": "^5.0.0"
  },
  "devDependencies": {
    "@repo/config-typescript": "workspace:*",
    "@types/express": "^5.0.0",
    "tsx": "^4.19.0",
    "typescript": "^5.7.0"
  }
}
// apps/api/src/index.ts
import express from "express";
import type { User, ApiResponse, UserCreateInput } from "@repo/shared";
import { formatDate } from "@repo/shared";

const app = express();
app.use(express.json());

app.get("/api/users", (req, res) => {
  const response: ApiResponse<User[]> = {
    success: true,
    data: [],
  };
  res.json(response);
});

app.post("/api/users", (req, res) => {
  const input: UserCreateInput = req.body;
  // バリデーション・保存処理
  res.json({ success: true, data: { ...input, id: 1 } });
});

app.listen(4000, () => {
  console.log("API server running on http://localhost:4000");
});

CI/CDの設定

GitHub Actionsの設定

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      # Turborepoのリモートキャッシュ
      - name: Build
        run: pnpm turbo build --filter='...[HEAD^1]'

      - name: Lint
        run: pnpm turbo lint --filter='...[HEAD^1]'

      - name: Test
        run: pnpm turbo test --filter='...[HEAD^1]'

--filter='...[HEAD^1]'により、前回のコミットから変更があったパッケージとその依存先だけがビルド・テスト対象になります。

パッケージの追加・依存関係の管理

# 特定のworkspaceにパッケージを追加
pnpm add zod --filter=api

# ルートにdevDependencyを追加
pnpm add -D prettier -w

# workspace間の依存を追加
pnpm add @repo/shared --filter=web --workspace

# すべてのworkspaceで依存を更新
pnpm update --recursive

まとめ

pnpm WorkspacesとTurborepoの組み合わせは、TypeScriptモノレポの構築において実績のある選択肢です。

pnpm Workspacesが提供するのは、効率的な依存関係管理、ワークスペース間のパッケージ参照、そしてstrict node_modulesによる安全なモジュール解決です。

Turborepoが提供するのは、依存グラフに基づいたタスクの順序制御と並列実行、ローカル・リモートキャッシュによるビルド高速化、そして変更検知フィルターによるCIの効率化です。

モノレポの導入は、共有コードが2つ以上のアプリで必要になった時点で検討する価値があります。初期セットアップには手間がかかりますが、プロジェクトが成長するにつれて、その恩恵は大きくなります。まずはsharedパッケージで型定義を共有するところから始めてみてください。

#TypeScript#モノレポ#Turborepo
共有:
無料メルマガ

週1回、最新の技術記事をお届け

AI・クラウド・開発の最新記事を毎週月曜にメールでお届けします。登録は無料、いつでも解除できます。

プライバシーポリシーに基づき管理します

起業準備に役立つ情報、もっとありますよ。

まずは話だけ聞いてもらう