Discovery
Before generating anything, ask (or infer from context):
- Architecture: Monorepo (Turborepo/Nx/pnpm workspaces) or separate repos?
- Schema source of truth: Database schema (Prisma), API spec (OpenAPI/GraphQL), or hand-written TypeScript?
- Stack: What's the backend (Express, tRPC, Fastify, NestJS)? What's the frontend (Next.js, React, Vite)?
- Existing drift problem? — Are types already duplicated, or is this greenfield?
Strategy: Pick One Pattern
Choose based on answers above. Don't mix patterns.
| Situation | Pattern |
|---|---|
| Monorepo, schema-first (Prisma/OpenAPI) | Generated types package |
| Monorepo, hand-written types | Shared @repo/types package |
| Separate repos | Published npm package + version pinning |
| tRPC already in use | Router inference — no separate package needed |
Non-Obvious Implementation Notes
Pattern A: Shared @repo/types Package (Monorepo)
The non-obvious parts:
packages/types/
├── package.json ← name: "@repo/types"
├── tsconfig.json ← must extend root tsconfig
└── src/
└── index.ts
packages/types/package.json — the critical field people miss:
{
"name": "@repo/types",
"exports": {
".": {
"types": "./src/index.ts", // ← point to source, not dist
"default": "./src/index.ts" // ← avoids a build step entirely
}
}
}
Root tsconfig.json needs path aliases or the package won't resolve:
{
"compilerOptions": {
"paths": {
"@repo/types": ["./packages/types/src/index.ts"]
}
}
}
Why source-first matters: Skipping a build step eliminates the most common drift source — forgetting to rebuild the types package before running the app. Both consumer apps see live TypeScript source.
Pattern B: Prisma → Generated Types (Schema-First)
Prisma exports its types but they're tied to @prisma/client. Don't re-export them directly into a shared package — the frontend will try to import server-only code.
Correct approach — extract only what's safe:
// packages/types/src/index.ts
import type { User, Post } from "@prisma/client"
// Re-export only plain data shapes (no Prisma methods, no PrismaClient)
export type { User, Post }
// For frontend-safe subsets, use Pick/Omit explicitly:
export type PublicUser = Pick<User, "id" | "name" | "email">
Add a postgenerate hook so types stay in sync automatically:
// package.json (root)
{
"scripts": {
"db:generate": "prisma generate && tsc -p packages/types/tsconfig.json --noEmit"
}
}
Pattern C: OpenAPI → TypeScript (API-First)
Use openapi-typescript (not swagger-typescript-api — the output is harder to use):
npx openapi-typescript ./openapi.yaml -o packages/types/src/api.ts
The generated output uses a paths namespace — people usually want helper types:
// packages/types/src/helpers.ts
import type { paths } from "./api"
// Extract request/response types without manually indexing paths every time
export type ApiResponse<P extends keyof paths, M extends keyof paths[P]> =
paths[P][M] extends { responses: { 200: { content: { "application/json": infer R } } } }
? R
: never
// Usage: ApiResponse<"/users/{id}", "get">
Pattern D: tRPC (Inference — No Package Needed)
If you're already on tRPC, a shared types package is redundant. The router IS the type contract.
// server: export the router type only
export type AppRouter = typeof appRouter
// client: import and use
import type { AppRouter } from "@repo/server"
const trpc = createTRPCProxyClient<AppRouter>(...)
Non-obvious: Export only typeof appRouter, never the router itself — keeping backend deps off the frontend.
Pattern E: Zod as the Single Source of Truth
Define schemas once, derive types everywhere:
// packages/types/src/user.ts
import { z } from "zod"
export const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(["admin", "user"]),
})
export type User = z.infer<typeof UserSchema>
// Backend: use schema for validation
// Frontend: use schema for form validation + type inference
// Both: same source, zero drift
Preventing Type Drift
CI Check (most teams skip this, don't)
Add a typecheck step that spans all packages:
// package.json (root)
{
"scripts": {
"typecheck": "tsc --build --force"
}
}
Requires each package to have a proper tsconfig.json with composite: true and references set up.
The satisfies Operator (TS 4.9+)
Catches shape mismatches that as masks:
// backend
const handler: RequestHandler = (req, res) => {
const body = req.body satisfies CreateUserInput // errors if shape changed
}
Shared Enum Pitfall
Don't use TypeScript enum in shared packages — it compiles to an IIFE and adds runtime overhead on the frontend. Use const objects + type union instead:
// ❌ shared enum
export enum Role { Admin = "admin", User = "user" }
// ✅ shared const + union
export const Role = { Admin: "admin", User: "user" } as const
export type Role = typeof Role[keyof typeof Role]
Output Checklist
- Single source of truth identified (Prisma / OpenAPI / Zod / hand-written)
- Types package or inference pattern set up
- No server-only imports leaking into the types package
-
typecheckscript runs across all packages in CI - Shared enums use
const+ union, not TypeScriptenum - If schema-generated: postgenerate hook wired up