本文へ移動
Zomer Gregorio

Zomer Gregorio

ソフトウェアエンジニア

履歴書
言語
← ブログ

TypeScript: How to Structure End-to-End Type Safety Across Monorepos

· TypeScript · Monorepo · Architecture · Type Safety

Learn how to enforce strict end-to-end type safety across TypeScript monorepo boundaries without circular dependencies or fragile build steps.

更新情報を受け取る

新しい記事を公開したときに短いお知らせを送ります。メールまたはブラウザの購読情報は通知配信のためだけに保存され、いつでも解除できます。アカウントや追跡用プロフィールは不要です。

通知を開始する前に確認メールでの承認が必要です。

Introduction to Monorepo Type Boundaries

Scaling a TypeScript monorepo introduces complex compilation and dependency challenges, particularly when sharing code between backend services, frontend applications, and shared packages. While workspace setups in package managers like pnpm, yarn, or npm make linking local packages trivial, they often hide deep architectural pitfalls. The most common failure mode is the accidental introduction of circular dependencies and loose project references, which degrades IDE performance, slows down incremental builds, and silently breaks runtime contracts.

Achieving true end-to-end type safety requires treating package boundaries as strict compilation units. This article outlines patterns for configuring TypeScript project references, isolating workspace boundaries, and sharing data models without leaking implementation details or runtime code.

Designing Package Boundaries

A resilient monorepo separates packages by domain and architectural layer. Mixing database access layers, UI components, and HTTP client definitions into a single shared package destroys incremental compilation benefits and complicates dependency graphs.

Consider a standard monorepo structure containing a backend API, a web client, and a shared domain package:

  • packages/domain
  • packages/api-client
  • apps/backend
  • apps/web

The packages/domain package should contain pure data models, validation schemas (such as Zod), and shared types. It must have zero runtime dependencies on external frameworks or databases. This guarantees that both the backend and frontend can consume domain models without pulling in unwanted heavy dependencies.

Configuring TypeScript Project References

To prevent the TypeScript compiler from scanning the entire monorepo during every incremental build, you must enable composite: true and use references in your tsconfig.json files. This transforms workspace packages into distinct compilation targets.

Here is a production-ready configuration for a shared domain package:

{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "strict": true,
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  },
  "include": ["src"]
}

Consuming packages must reference this project in their own tsconfig.json. This tells the TypeScript language server and compiler to rely on pre-compiled declaration files (.d.ts) rather than parsing raw source files across workspace boundaries.

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  },
  "references": [
    { "path": "../../packages/domain" }
  ]
}

Failing to configure project references forces the TypeScript language server to continuously parse raw source files across all linked workspaces, which rapidly degrades IDE responsiveness once a monorepo exceeds a few thousand source files.

Sharing API Contracts Safely

A frequent source of bugs in full-stack applications is drift between backend request-response handlers and frontend API clients. By defining API contracts in a shared package using runtime validation libraries alongside TypeScript types, you eliminate this drift.

Instead of manually duplicating interface definitions or relying on loosely typed HTTP wrappers, define your procedures or endpoints with explicit type inference:

import { z } from 'zod';
 
export const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  role: z.enum(['admin', 'member']),
});
 
export type User = z.infer<typeof UserSchema>;
 
export const CreateUserRequestSchema = UserSchema.omit({ id: true });
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;

When the backend handler processes an incoming request, it parses the payload against CreateUserRequestSchema. Because the client imports this exact schema from the shared domain package, any change to the required fields immediately surfaces as a type error during compilation on both sides of the application.

Handling Runtime vs. Compile-Time Boundaries

A critical trap in monorepo design is conflating compile-time types with runtime execution. TypeScript interfaces and type aliases vanish during compilation. If a shared package exports a type that relies on a database driver or Node.js built-in module, importing that type into a browser-targeted frontend application will break the bundler or introduce runtime crashes.

To enforce this boundary, utilize the exports field in your shared packages' package.json to explicitly control what subpaths and modules can be consumed:

{
  "name": "@repo/domain",
  "exports": {
    "./types": {
      "types": "./dist/types.d.ts",
      "default": "./dist/types.js"
    }
  }
}

By restricting exports and avoiding root-level wildcards, you prevent developers from accidentally importing server-only utilities into client bundles.

Verification and Continuous Integration

Ensuring that your monorepo remains type-safe requires strict CI verification steps. Running a simple typecheck across the entire workspace is insufficient if project references are misconfigured.

Your CI pipeline should execute builds in topological order, respecting the dependency graph generated by your workspace manager and TypeScript project references. For instance, using pnpm, you can run:

pnpm --recursive --parallel run build

Ensure that your root-level CI script explicitly runs tsc --build to validate that all project reference outputs are up to date and that no stale declaration files exist. If a developer modifies a shared package without running the build step locally, tsc --build in CI will catch the discrepancy immediately.

Summary

Maintaining end-to-end type safety in a TypeScript monorepo is an exercise in strict boundary management. By enforcing pure domain packages, activating TypeScript project references, utilizing runtime validation schemas for API contracts, and restricting package exports, you achieve rapid compilation speeds, robust refactoring capabilities, and zero type drift between services.