Skip to main content
友田 陽大
Type safety & validation
モノレポ
TypeScript
アーキテクチャ設計
CI/CD
型安全

Designing a Type-Safe Monorepo with pnpm + Turborepo: Kill Drift with catalog, Make the Shared Domain Type the Single Source of Truth

An implementation guide to designing a production TypeScript monorepo with pnpm workspaces + Turborepo. Explained with real code: pinning dependency versions with catalog, turbo.json's task pipeline and caching, making the Zod shared-domain package the single source of truth, CI-enforcing type coverage, and detecting schema drift.

Published
Reading time
22 min read
Author
友田 陽大
Share

"The apps became 2" — this is usually where you start considering a monorepo. The learner-facing front end, the operator's admin screen, the common domain definitions, the DB types, the Edge Function. When each scatters into a separate repository, the same concepts of "user," "course," and "subscription" come to be defined with slightly-divergent types per repository. You fix one and forget to fix the other. The versions drift apart before you know it. And in production, "the types passed but it breaks" happens.

A monorepo is the tool that solves this. But a disorderly monorepo is more hellish than polyrepo. Everything is crammed into one giant dependency graph, the build is slow, circular dependencies tangle, and no one grasps which package, when fixed, breaks what.

This article is an implementation guide for designing "a monorepo that grows type-safely" with pnpm workspaces + Turborepo + a Zod shared-domain package. As a subject, I weave in the design judgments that actually worked in the financial-literacy education subscription learning platform I built (a monorepo of a learner app, operator admin, and 14 shared packages), and in a separate project's real-time sports-scoring app. While showing config faithful to the official documentation, I dig into "why do it that way."

The rule of this article: the specs of config keys / protocols are based on the pnpm official documentation / Turborepo official documentation (as of June 2026). Config key names change by version (Turborepo has the precedent of renaming the old pipeline → the current tasks). Always confirm the latest official version before shipping to production. The code is shaped to be usable in real operation, but secrets presuppose environment variables (hardcoding strictly forbidden).


0. The Mental Model: A Monorepo's Value Is "Shared Vocabulary × Bulk Type-Check"

First, let me fix just one way of thinking that runs through this article.

A monorepo's value = "multiple apps share the same domain vocabulary, and you can type-check in bulk when that vocabulary changes."

Add paused to Subscription's state, and all switch statements in the learner app, the admin screen, the DB types, and the Edge Function become compile errors, telling you "fix here too" — this is the experience you want to get from a monorepo. Conversely, a monorepo that doesn't deliver this experience is just "folders bundled together," dropping most of the value.

There are 4 keys to making this experience hold. This article proceeds in this order.

  1. Kill dependency drift → pnpm catalog (pin shared dependency versions in one place)
  2. Make build/test fast with cachingTurborepo (task pipeline + caching)
  3. Make the shared type the single source of truth → the domain package (consolidate Zod schemas in one place)
  4. Mechanically enforce discipline in CI → a type-coverage gate, schema-drift detection (don't rely on human will)

Only when these 4 are in place does a monorepo become "an asset you can grow." Miss even one and it crumbles the bigger it gets.


1. pnpm workspaces: Building the Foundation

1.1 pnpm-workspace.yaml: Which Are Packages

A pnpm workspace begins by declaring, with globs, "which directories are packages" in the pnpm-workspace.yaml directly under the repository. The official format is this.

# pnpm-workspace.yaml
packages:
  - "apps/*"        # 受講者アプリ・管理画面など「最終成果物」
  - "packages/*"    # domain / ui / config など「共有部品」
  - "!**/test/**"   # テストディレクトリは除外

The official documentation shows the format of enumerating glob patterns (packages/*, components/**, the exclusion !**/test/**) in the packages field. The directory structure I make standard is this 2-layer one.

my-monorepo/
├── pnpm-workspace.yaml
├── turbo.json
├── package.json          # ルート(private: true、共通scriptのみ)
├── apps/
│   ├── learner/          # 受講者アプリ(Next.js)
│   └── admin/            # 運営管理(Next.js)
└── packages/
    ├── domain/           # Zod ドメインスキーマ(単一真実源)
    ├── ui/               # 共有UIコンポーネント
    ├── config-eslint/    # 共有ESLint設定
    └── config-ts/        # 共有tsconfig

Decide upfront the distinction that apps/ is "final deliverables that never become depended-upon," and packages/ is "shared parts depended on by multiple apps." This boundary becomes the premise for the later circular-dependency countermeasure (Chapter 6).

1.2 workspace:*: Make Local References Explicit

When an app uses a shared package, write the workspace protocol in the package.json dependency. This is the explicit instruction "use a package within this workspace, not from the npm registry."

// apps/learner/package.json
{
  "name": "@app/learner",
  "private": true,
  "dependencies": {
    "@repo/domain": "workspace:*",
    "@repo/ui": "workspace:*"
  }
}

The official docs state that workspace: has multiple variants.

NotationMeaning
workspace:*Matches any version within the workspace (this is the default within a monorepo)
workspace:^Treated as a caret range
workspace:~Treated as a tilde range
workspace:2.0.0An exact version specification

Auto-conversion on publishing: per the official docs, the moment you archive with pnpm publish / pnpm pack, workspace: is dynamically replaced with the actual version. Like workspace:*1.5.0, workspace:~~1.5.0, workspace:^^1.5.0. That is, you develop with workspace:* internally, and it becomes the real version only on external publication — you achieve both internal convenience and post-publish semver compatibility. For an internal-only package (private: true), since it isn't published at all, you needn't agonize over workspace:*.

Before as / any, let me put in one discipline here. In my projects, I make all shared packages private: true (a safety valve against accidentally publishing something not meant for external release). Not bringing publish-flow considerations into something with no plan to publish is YAGNI.


2. pnpm catalog: "Structurally" Kill Dependency Drift

This is the point that fails most silently in monorepo operation.

2.1 What's the Problem: Silent Version Drift

With workspace:*, the versions of internal packages are aligned. But external dependencies like zod, react, typescript are written individually by each package.json. The learner app is zod@3.23, the admin screen is zod@3.22, the domain package is zod@3.24… and they drift apart without anyone intending it. This is silent drift.

What happens? When zod changes type behavior between majors, the type the domain package returns and the type the app's receiving zod interprets diverge. "The domain type meant to be the single source of truth is interpreted as a different thing on the consuming side" — an accident where the monorepo's value itself crumbles. Multiple versions of zod get bundled together, bloating it.

2.2 catalog: Consolidate Versions in One Place

pnpm's catalog is a mechanism that consolidates these shared dependency versions in one place in pnpm-workspace.yaml. The official format is this.

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

# デフォルトカタログ(単数形 catalog)
catalog:
  zod: ^3.24.1
  react: ^19.2.0
  react-dom: ^19.2.0
  typescript: ^5.6.3

# 名前付きカタログ(複数形 catalogs)— 移行期に複数バージョンを共存させたいとき
catalogs:
  react18:
    react: ^18.2.0
    react-dom: ^18.2.0

Each package's package.json references the version with the catalog: protocol without writing it directly.

// packages/domain/package.json
{
  "name": "@repo/domain",
  "private": true,
  "dependencies": {
    "zod": "catalog:"
  }
}
// apps/learner/package.json
{
  "dependencies": {
    "@repo/domain": "workspace:*",
    "react": "catalog:",
    "react-dom": "catalog:",
    "zod": "catalog:"
  }
}

When using a named catalog, write catalog:<name>.

{
  "dependencies": {
    "react": "catalog:react18"
  }
}

Per the official docs, the catalog: protocol can be used in dependencies, devDependencies, peerDependencies, optionalDependencies, and pnpm-workspace.yaml's overrides. Because it's auto-replaced with the actual version range on pnpm publish / pnpm pack, it can be used safely even in published packages.

2.3 What catalog Brings

The official docs express catalog's benefit as the single source of truth. Organizing it in my words, tied to design principles:

  • DRY (single source of truth): the "knowledge" of zod's version exists in just one place within the repository. The same ^3.24.1 doesn't scatter across 3 package.json files.
  • One-shot upgrades: when you want to bump zod, just change one line in pnpm-workspace.yaml. All packages follow. "I forgot to fix that app's package.json" becomes structurally impossible.
  • Reduced merge conflicts: because package.json isn't rewritten on dependency updates, multiple people's dependency updates don't clash.

My real operation: in a separate project's real-time sports-scoring app, I centrally pinned the main dependencies like zod and react with pnpm catalog, eradicating silent drift. catalog turns "preventable if you're careful" into "prevented even if you're not careful" — a textbook of moving discipline from human attentiveness to the tool's structure. This is the first thing to do in a monorepo.


3. Turborepo: The Task Pipeline and Caching

Once the workspace is set up and dependencies aligned, next it's time to run build/test/lint fast and in the correct order. Here you use Turborepo.

3.1 What Turborepo Is

The official docs define Turborepo as "a high-performance build system for JavaScript / TypeScript codebases. Designed to scale monorepos." Against the problem a monorepo holds — "each workspace has its own test, lint, and build, resulting in thousands of tasks" — Turborepo parallelizes tasks and optimizes execution. In the official words, "Turborepo schedules your tasks for maximum speed, parallelizing work across all available cores."

And the core is caching. "Never do the same work twice."

3.2 turbo.json's tasks: Declare the Dependency Order

The config file is turbo.json. The task-config key in the current docs is tasks (note: in earlier versions it was named pipeline. If you see an old article or an old turbo.json, read it accordingly. Version differences are easy to step on here).

Here's a practical turbo.json faithful to the official example.

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

A note (confirm): the $schema value https://turbo.build/schema.json is a widely-used standard value, but due to the domain migration (turborepo.com → turborepo.dev), the schema URL may change by version. Treat the value of your generated turbo.json as authoritative.

Let me pin down the meaning of each key, per the official definitions.

  • dependsOn: tasks that must complete before that task.
  • ^build's ^ (caret): per the official docs, "^ is an instruction to first run that task in your direct dependency packages." "dependsOn": ["^build"] is "before building myself, first build all packages I depend on." Build @repo/domain, then build @app/learner — this topological order is automatically derived from the dependency graph.
  • "dependsOn": ["build"] without ^: a dependency on another task within the same package. test means "after the same package's build."
  • The pkg#task form (e.g. "dependsOn": ["utils#build"]): name a specific task in a specific package.

3.3 outputs and inputs: Define the Cache Boundary

The correctness of the cache is decided by these 2.

  • outputs: the files/directories a task generates and should cache. The official docs warn clearly — "if you don't declare a task's file outputs, Turborepo won't cache it." For Next.js, excluding !.next/cache/** from .next/** is the standard.
  • inputs: the files included in the task's hash. When these change, it's a "cache miss = re-run." For example, to re-run spell-check only when Markdown changes:
{
  "tasks": {
    "spell-check": {
      "inputs": ["**/*.md", "**/*.mdx"]
    }
  }
}

To keep the default inputs while excluding some, combine with $TURBO_DEFAULT$.

{
  "tasks": {
    "build": {
      "inputs": ["$TURBO_DEFAULT$", "!README.md"]
    }
  }
}

Changing README.md doesn't invalidate the build cache — a cost optimization of "don't throw away the cache on a change that doesn't substantially affect the build result."

3.4 Side-Effect Tasks Are cache: false

A task with side effects like deployment must not be cached (a cache hit causes the accident of "you meant to deploy but nothing happened"). Per the official docs, attach cache: false. A resident task like dev is persistent: true.

{
  "tasks": {
    "deploy": { "cache": false },
    "dev": { "cache": false, "persistent": true }
  }
}

4. How Caching Works, and Making CI Dramatically Faster

4.1 Local Cache: Don't Do the Same Work Twice

Per the official docs, Turborepo creates a fingerprint (hash) from the task inputs on the first run, and on a re-run with the same input, restores the result from .turbo/cache. Terminal output (logs) is always captured too and replayed from the cache.

The hash is composed of 2 layers (the official classification).

  • Global hash: changes to the root/package turbo.json, the root package.json and lockfile, the files of globalDependencies, the variables of globalEnv, and behavior-changing flags like --cache-dir.
  • Package hash: changes to that package's turbo.json, the dependencies of package.json, and the source-controlled files set by inputs.

Here Chapter 2's catalog pays off. Because changes to the lockfile are included in the global hash, consolidating dependencies with catalog reduces the noise of "the cache invalidated by an unrelated dependency drift," raising the cache hit rate. catalog works not only for type safety but for cache efficiency.

4.2 Remote Cache: Share the Cache with CI and the Team

The local cache stops at "don't do the same work twice on your machine." The real forte is the remote cache. In the official words, "Remote Cache stores the results of all your tasks, eliminating the need for CI to do the same work twice." Enabling it is 2 commands.

npx turbo login
npx turbo link

With this, task results are automatically uploaded to the remote cache, and another authenticated machine (= CI or another member) gets an immediate cache hit. In the official words, "share a cache across your entire team and CI."

The effect is concrete. If a PR touched only packages/ui, @repo/domain's build and test reuse the previous result as-is, and CI computes only the diff. Not rebuilding what wasn't touched — this is the true nature of turning a monorepo's CI from "redo everything" into "just the diff," directly connected to cost efficiency (CI time = billed time).

A design implication: if you want fast CI, first correctly declare outputs. A task without outputs declared isn't cached, dropping the entire benefit of the remote cache. A good number of "CI is slow" cases are actually a forgotten outputs.


5. The Shared Domain Package: Make Zod the Single Source of Truth

This is the heart of monorepo design. Even if you align versions with catalog and go fast with Turborepo, if the crucial types are defined separately per app, most of the point of making it a monorepo is lost.

5.1 Why "Zod Schemas" and Not "Type Definitions"

There are 2 options.

ApproachContentProblem
① Share only TypeScript type/interfaceCompile-time types onlyCan't validate at runtime. No guarantee that API responses, DB rows, and form input match the types
Share Zod schemas and derive types from themGet both a "runtime validator" and a "static type" from one schema(this is the mainstay)

Boundaries (API, DB, Edge Function, user input) are all "unvalidated data from outside." Casting them through with as Course is the act of lying to the type system, and it will surely break in production. With Zod, you get a runtime validation and a static type simultaneously from one schema, and can make the discipline of believing the type only after parse-ing at the boundary. This is my projects' iron rule, "boundaries are Zod-validated."

5.2 packages/domain: The Implementation

// packages/domain/src/subscription.ts
import { z } from "zod";

/**
 * サブスクリプションの状態。
 * ここが全スタックの単一真実源。値を増やすと、消費側の網羅スイッチが
 * すべてコンパイルエラーになって「ここも直せ」と教えてくれる(後述の NeverError)。
 */
export const SubscriptionStatus = z.enum([
  "trialing",
  "active",
  "past_due",
  "canceled",
]);
export type SubscriptionStatus = z.infer<typeof SubscriptionStatus>;

export const Subscription = z.object({
  id: z.string().uuid(),
  userId: z.string().uuid(),
  status: SubscriptionStatus,
  currentPeriodEnd: z.coerce.date(),
});
// スキーマから静的型を「導出」する。型を別途手書きしない(DRY)。
export type Subscription = z.infer<typeof Subscription>;
// packages/domain/package.json
{
  "name": "@repo/domain",
  "private": true,
  "type": "module",
  "exports": { ".": "./src/index.ts" },
  "dependencies": { "zod": "catalog:" }
}

The point is to derive the type with z.infer. If you hand-write the schema and the type separately, you fix one and forget to fix the other — the very drift you want to avoid recurs inside the package. Make one schema the source of truth, and derive the type mechanically from it (DRY = single source of truth).

5.3 Use It from Each App: parse at the Boundary, Believe the Type Inside

// apps/learner/src/api/subscription.ts
import { Subscription, type Subscription as TSubscription } from "@repo/domain";

/**
 * 外部(API/DB)からの未検証データは、必ず境界で parse する。
 * parse を通った後の値だけが TSubscription として信頼できる。
 * ここで as は使わない——型システムへの嘘は本番で破綻する。
 */
export async function fetchSubscription(userId: string): Promise<TSubscription> {
  const res = await fetch(`/api/subscriptions/${userId}`);
  const json: unknown = await res.json();      // 入口は unknown
  return Subscription.parse(json);             // 境界で検証 → ここから型を信じる
}

Because @repo/domain is referenced with workspace:*, the moment you change the domain's schema, all consumption points in the learner app, admin screen, and Edge Function receive the same type change. This is the true intent of a monorepo.

5.4 Mechanically Enforce Exhaustiveness with NeverError

When you add paused to SubscriptionStatus, you want all switches branching on the state to become compile errors of "you forgot to handle paused." What makes this hold is my projects' discipline, "ban enum, ban non-null, ban as/any + NeverError."

// packages/domain/src/never-error.ts
/** 網羅し損ねた case を「コンパイル時に」検出する番人。 */
export class NeverError extends Error {
  constructor(value: never) {
    super(`Unreachable: unexpected value ${JSON.stringify(value)}`);
  }
}
// 消費側:status の分岐
import { NeverError } from "@repo/domain";

function label(status: SubscriptionStatus): string {
  switch (status) {
    case "trialing":  return "トライアル中";
    case "active":    return "有効";
    case "past_due":  return "支払い遅延";
    case "canceled":  return "解約済み";
    // paused を domain に足すと、ここで `never` ではなくなり型エラー → 直し漏れゼロ
    default:          throw new NeverError(status);
  }
}

Why ban TS's enum: TS's enum generates a superfluous object at runtime, const enum is incompatible with isolated compilation and bundlers, and numeric enums have holes in type safety. With a string-literal union z.infer-ed from Zod's z.enum([...]), you get both runtime validation (Zod) and static exhaustiveness (never), without adding superfluous runtime. Not "use it because it's a language feature" but "does it serve the single source of truth and exhaustiveness" — this is the core of the type-safety discipline.


6. Mechanically Enforce Discipline in CI: Type Coverage and Schema-Drift Detection

The design so far is meaningless unless humans uphold it. And humans will surely forget to uphold it. So enforce it mechanically in CI. "Be careful in review" is not discipline.

6.1 tsconfig: Tighten Beyond strict

In the shared tsconfig (packages/config-ts), put in 2 more clamps on top of strict. This is my standard.

// packages/config-ts/base.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,    // arr[i] を T ではなく T | undefined に
    "exactOptionalPropertyStrict": true,  // ※キー名は版確認。下の注を参照
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true
  }
}

A note (confirm): the official key of the option "don't allow explicit undefined assignment to an optional property" is exactOptionalPropertyTypes (the above exactOptionalPropertyStrict is placed as an example easy to mistype — be sure to use exactOptionalPropertyTypes). Together with noUncheckedIndexedAccess, the aim is to surface in the type the "seemingly-present-but-actually-absent" of array access and optionals.

  • noUncheckedIndexedAccess: treats array[0] as T | undefined. Reflects in the type the reality that "index access can miss," cutting the habit of crushing it with ! (non-null).
  • exactOptionalPropertyTypes: makes { name?: string } unable to be explicitly assigned undefined. Distinguishes "unspecified" from "specified undefined," erasing boundary ambiguity.

6.2 Make Type Coverage a CI Gate

Even with strict, any seeps in (the returns of external libraries, JSON.parse, sloppy casts). What quantifies this is type coverage — "the proportion of the codebase that is typed." Measure it with a tool like type-coverage, and fail CI if it falls below a threshold.

// 各パッケージの package.json(domain は最も厳しく)
{
  "scripts": {
    "type-coverage": "type-coverage --detail --strict --at-least 99.5"
  }
}

My real operation: in a separate project, I CI-enforced TypeScript strict + type coverage, enabled noUncheckedIndexedAccess / exactOptionalPropertyTypes, and set the type-coverage threshold per package at 96.7%–100%. A source-of-truth package like domain is 100%, the UI's edges a bit looser — being able to vary the "type strictness" per package is the advantage of monorepo + shared config. Because the threshold is a committed number, when someone adds one any, CI turns red and it becomes visible.

6.3 Schema-Drift Detection: Catch "Merged but Not Deployed"

Finally, a discipline that tends to be overlooked. The code is merged, but the corresponding schema (DB types, API schema) isn't reflected in the deployment environment — this divergence (drift) is the hotbed of bugs that occur only in production.

I assemble a mechanism to detect schema drift with GitHub Actions. The idea is simple — regenerate "the types/schema that should be generated from the source of truth (domain / migrations)" and diff against the committed ones. If a diff appears, fail CI as "the generated artifact is stale = drift."

# .github/workflows/schema-drift.yml(要点のみ)
name: schema-drift
on: [pull_request]
jobs:
  drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      # domain スキーマ / DB 型を「あるべき姿」に再生成
      - run: pnpm --filter @repo/domain generate
      # 生成結果がコミット済みと一致するか。ズレていれば drift → 失敗
      - run: git diff --exit-code -- packages/domain/generated

My real operation: in a separate project, I automated type checking, lint, type coverage, and schema-drift detection with 11 GitHub Actions. The aim is to have machines detect the divergence of state that human eyes can't track, like "merged but not deployed." What's common to all of Chapter 6 is the philosophy of — guaranteeing discipline not with human goodwill but with CI's red light. catalog (dependency drift), type coverage (type drift), and schema-drift (schema drift) are all the manifestation of one principle: "let machines kill drift."

6.4 Dependency Boundaries: Prevent Circular Dependencies

The biggest cause of a monorepo rotting is circular dependencies. Form a loop like apps/learner → packages/ui → apps/learner, and the build order can't be decided, and Turborepo's topological cache breaks too.

The discipline is simple — flow dependencies in one direction. Chapter 1's separation of apps/ and packages/ works here.

  • apps/* may depend on packages/*. The reverse is forbidden (a shared part must not know an app).
  • Dependencies among packages/* also keep a hierarchy: ui → domain is OK, domain → ui is not. domain is the lowest layer that depends on nothing (other than Zod).

Build it into CI with ESLint's import/no-cycle rule, or a tool that declaratively constrains dependency direction, and fail the PR the moment a cycle is born. "Fix it later" never gets fixed, so stopping it at the entrance is the only solution.


7. Monorepo vs. Polyrepo: When Does a Monorepo Win

Read this far and it looks like "monorepo is the best," but it's not always the right answer. Choose by decision axes.

Decision axisMonorepo is advantageousPolyrepo is advantageous
Shared domainMultiple apps densely share the same types/vocabulary (like this time's learner + admin)Each service's domain is mostly independent
Cross-cutting changeYou want to type-check one change across all apps in bulkChanges are often confined to one service
Team structureSmall to mid-scale, can see the wholeMany independent teams move on separate release cycles
ReleaseSynchronized releases (want to ship together) are commonYou want independent deploys per service
Tech stackCentered on TS, can unify the toolchainLanguages/runtimes are mixed (Go, Rust, Node together)
CICan speed up with Turborepo's diff cacheYou want to avoid monorepo CI's complexity

Why this time's subscription learning platform suited a monorepo is clear. The learner app and admin screen share the same Course, Subscription, User, and I wanted to type-check both in bulk when the domain changed. The stack was unified in TS, and being small I could see the whole. When the 3 of "shared domain × cross-cutting type-check × unified stack" align, a monorepo is strong. Conversely, if these don't align, there's no reason to discard polyrepo's simplicity.

What to Carve Out into a Shared Package

Even if you make a monorepo, slicing everything into packages is a mistake (over-splitting needlessly complicates the build graph = YAGNI). The criterion is this.

Carve outDon't carve out
Domain types that 2 or more apps actually share (domain)Logic only one app uses (place it in that app)
Cross-cutting config (config-eslint, config-ts)A speculative part of "maybe share it someday"
A shared-UI part that appeared 3 or more timesA part that appeared only twice (it may be a coincidence)

Per the DRY principle — "twice is coincidence, three times is a pattern." Panic-package at 1–2 duplications and you're bound to a wrong abstraction. Carve it out after actual sharing is observed (ETC = to keep change easy, delay the abstraction). domain alone is the exception, carved out from the start. Because that's the very reason a monorepo exists.


8. Summary: A Type-Safe Monorepo Cheat Sheet

A quick reference for when you're lost.

  • Foundation: separate apps/* and packages/* in pnpm-workspace.yaml. Internal references are workspace:*, shared parts are private: true.
  • Eradicate drift: consolidate shared external dependencies (zod, react, typescript) in catalog, and each package.json references with catalog:. A version update is one line.
  • Speed: in turbo.json's tasks (old pipeline), declare the topological order with dependsOn: ["^build"]. Always write outputs (without them, no caching). Side-effect tasks are cache: false.
  • CI speedup: remote cache with turbo login + turbo link. Don't rebuild what wasn't touched = CI billed time drops.
  • Single source of truth: consolidate Zod schemas in packages/domain, derive types with z.infer. Always parse at the boundary, don't use as inside. Mechanically enforce exhaustiveness with NeverError.
  • Mechanical enforcement of discipline: strict + noUncheckedIndexedAccess + exactOptionalPropertyTypes. Make the type-coverage threshold a CI gate. Catch "merged but not deployed" with schema-drift detection. Stop circular dependencies at the entrance.
  • When to quit: if the shared domain is thin and teams want to release independently, don't force a monorepo.

A monorepo is not "the technique of bundling folders" but the design of "multiple apps sharing the same domain vocabulary, type-checking changes in bulk, and mechanically enforcing discipline in CI." Kill drift with catalog, go fast with Turborepo, make types the single source of truth with Zod, and protect it in CI — only when these 4 pillars align does a monorepo withstand scale and grow.

I have actually operated a monorepo of a learner app, operator admin, and 14 shared packages, with a type-safety discipline that banned as/any/enum/non-null and multiple CI gates including type coverage and schema-drift detection. Using generative AI (Claude Code) to accelerate implementation, while guaranteeing quality with the mechanical verification gates of types and CI — this is my way of safely spinning a multi-surface monorepo even solo.

"The apps have grown and the types are starting to drift," "I want to make it a monorepo but not make it hell" — from that design, through building catalog, Turborepo, the shared-domain package, and the CI gates, I can accompany you all the way through. Feel free to consult me even from the requirements-organizing stage.


References (Official Documentation)

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading