The Full-Stack TypeScript Type Problem
You define a User type in your backend. You define it again in your frontend. They drift apart. A backend field becomes optional and nobody updates the frontend.
End-to-end type safety solves this. Here are the three main approaches.
Approach 1: tRPC (Best for Next.js Monorepo)
tRPC lets you call server functions from the client with full type inference -- no manual type duplication, no code generation.
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
// server/routers/user.ts
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { z } from 'zod'
export const userRouter = router({
getProfile: protectedProcedure
.query(async ({ ctx }) => {
return ctx.db.user.findUnique({ where: { id: ctx.session.user.id } })
}),
updateProfile: protectedProcedure
.input(z.object({ name: z.string().min(1).max(100) }))
.mutation(async ({ ctx, input }) => {
return ctx.db.user.update({
where: { id: ctx.session.user.id },
data: { name: input.name }
})
}),
})
// Client -- fully typed, no manual type import needed
const { data: profile } = trpc.user.getProfile.useQuery()
// profile is typed as Prisma.User | null automatically
const mutation = trpc.user.updateProfile.useMutation()
mutation.mutate({ name: 'Atlas' }) // TypeScript enforces the input shape
Approach 2: Zod Schemas as Source of Truth
// shared/schemas/user.ts (shared between frontend and backend)
import { z } from 'zod'
export const UserSchema = z.object({
id: z.string().cuid(),
email: z.string().email(),
name: z.string().nullable(),
plan: z.enum(['free', 'pro', 'enterprise']),
createdAt: z.string().datetime(),
})
export type User = z.infer<typeof UserSchema>
// Backend: validate DB output
const rawUser = await db.user.findUnique({ where: { id } })
const user = UserSchema.parse(rawUser) // Throws if shape is wrong
// Frontend: validate API response
const res = await fetch('/api/user/me')
const data = await res.json()
const user = UserSchema.parse(data) // Same validation, same types
Approach 3: OpenAPI + Generated Types
npm install @asteasolutions/zod-to-openapi openapi-typescript
// Generate OpenAPI spec from Zod schemas
import { OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi'
const registry = new OpenAPIRegistry()
registry.register('User', UserSchema)
const generator = new OpenApiGeneratorV3(registry.definitions)
const spec = generator.generateDocument({ openapi: '3.0.0', info: { title: 'API', version: '1.0.0' } })
// Then generate TypeScript types from the spec:
// npx openapi-typescript openapi.json -o src/api-types.ts
Keeping Types in Sync: The Rules
1. Single source of truth -- define each type ONCE
Good: Zod schema in shared/ folder
Bad: TypeScript interface in frontend, Prisma model in backend, OpenAPI schema in docs
2. Derive, don't duplicate
type User = z.infer<typeof UserSchema> -- derives from schema
type CreateUser = Omit<User, 'id' | 'createdAt'> -- derives from User
3. Validate at boundaries
Validate when data enters your system (API input, DB output, external API response)
Trust internal data passed between functions
4. Never cast with 'as'
as User is a lie. If you need to cast, your types are wrong.
This Is Configured in the AI SaaS Starter Kit
Shared Zod schemas, validated API routes, and typed database queries out of the box.
$99 one-time at whoffagents.com
Top comments (0)