"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 currenttasks). 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.
- Kill dependency drift → pnpm catalog (pin shared dependency versions in one place)
- Make build/test fast with caching → Turborepo (task pipeline + caching)
- Make the shared type the single source of truth → the domain package (consolidate Zod schemas in one place)
- 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.
| Notation | Meaning |
|---|---|
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.0 | An 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. Likeworkspace:*→1.5.0,workspace:~→~1.5.0,workspace:^→^1.5.0. That is, you develop withworkspace:*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 overworkspace:*.
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.1doesn't scatter across 3package.jsonfiles. - One-shot upgrades: when you want to bump
zod, just change one line inpnpm-workspace.yaml. All packages follow. "I forgot to fix that app's package.json" becomes structurally impossible. - Reduced merge conflicts: because
package.jsonisn'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
zodandreactwith 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
$schemavaluehttps://turbo.build/schema.jsonis 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 generatedturbo.jsonas 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.testmeans "after the same package'sbuild."- The
pkg#taskform (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 rootpackage.jsonand lockfile, the files ofglobalDependencies, the variables ofglobalEnv, and behavior-changing flags like--cache-dir. - Package hash: changes to that package's
turbo.json, the dependencies ofpackage.json, and the source-controlled files set byinputs.
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 withoutoutputsdeclared isn't cached, dropping the entire benefit of the remote cache. A good number of "CI is slow" cases are actually a forgottenoutputs.
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.
| Approach | Content | Problem |
|---|---|---|
① Share only TypeScript type/interface | Compile-time types only | Can't validate at runtime. No guarantee that API responses, DB rows, and form input match the types |
| ② Share Zod schemas and derive types from them | Get 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'senumgenerates a superfluous object at runtime,const enumis incompatible with isolated compilation and bundlers, and numeric enums have holes in type safety. With a string-literal unionz.infer-ed from Zod'sz.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
undefinedassignment to an optional property" isexactOptionalPropertyTypes(the aboveexactOptionalPropertyStrictis placed as an example easy to mistype — be sure to useexactOptionalPropertyTypes). Together withnoUncheckedIndexedAccess, the aim is to surface in the type the "seemingly-present-but-actually-absent" of array access and optionals.
noUncheckedIndexedAccess: treatsarray[0]asT | 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 assignedundefined. Distinguishes "unspecified" from "specifiedundefined," 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 likedomainis 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 oneany, 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 onpackages/*. The reverse is forbidden (a shared part must not know an app).- Dependencies among
packages/*also keep a hierarchy:ui → domainis OK,domain → uiis not.domainis 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 axis | Monorepo is advantageous | Polyrepo is advantageous |
|---|---|---|
| Shared domain | Multiple apps densely share the same types/vocabulary (like this time's learner + admin) | Each service's domain is mostly independent |
| Cross-cutting change | You want to type-check one change across all apps in bulk | Changes are often confined to one service |
| Team structure | Small to mid-scale, can see the whole | Many independent teams move on separate release cycles |
| Release | Synchronized releases (want to ship together) are common | You want independent deploys per service |
| Tech stack | Centered on TS, can unify the toolchain | Languages/runtimes are mixed (Go, Rust, Node together) |
| CI | Can speed up with Turborepo's diff cache | You 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 out | Don'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 times | A 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/*andpackages/*inpnpm-workspace.yaml. Internal references areworkspace:*, shared parts areprivate: true. - Eradicate drift: consolidate shared external dependencies (
zod,react,typescript) in catalog, and eachpackage.jsonreferences withcatalog:. A version update is one line. - Speed: in
turbo.json'stasks(oldpipeline), declare the topological order withdependsOn: ["^build"]. Always writeoutputs(without them, no caching). Side-effect tasks arecache: 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 withz.infer. Alwaysparseat the boundary, don't useasinside. Mechanically enforce exhaustiveness withNeverError. - 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)
- pnpm Workspaces —
pnpm-workspace.yaml, theworkspace:*protocol and conversion on publishing - pnpm Catalogs — making dependency versions the single source of truth with
catalog:/catalogs: - pnpm pnpm-workspace.yaml — the
packagesglob and catalog notation - Turborepo Docs (Overview) — the whole picture of the high-performance build system, parallelization, caching
- Turborepo Caching — local/remote cache, hashing,
turbo login/turbo link - Turborepo Configuring Tasks —
tasks,dependsOn,^build,outputs,inputs,cache,persistent