There's a particular kind of frustration that comes from seeing "Cannot read property 'name' of undefined" in production. You had types. TypeScript said everything was fine. But somewhere between your database and your frontend, reality diverged from your types.
Full-stack type safety solves this—not with more careful coding, but with architecture that makes type mismatches literally impossible.
Database Schema as Source of Truth
Drizzle ORM
// db/schema.ts
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').notNull().unique(),
name: text('name').notNull(),
role: text('role', { enum: ['admin', 'user', 'guest'] }).default('user'),
createdAt: timestamp('created_at').defaultNow(),
})
// Types automatically inferred
export type User = typeof users.$inferSelect
export type NewUser = typeof users.$inferInsertPrisma
model User {
id String @id @default(uuid())
email String @unique
name String
role Role @default(USER)
createdAt DateTime @default(now())
}
enum Role { ADMIN USER GUEST }Type-Safe API with tRPC
import { z } from 'zod'
import { router, publicProcedure } from './trpc'
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
return db.query.users.findFirst({
where: eq(users.id, input.id)
})
}),
create: publicProcedure
.input(z.object({
email: z.string().email(),
name: z.string().min(1),
role: z.enum(['admin', 'user', 'guest']).optional()
}))
.mutation(async ({ input }) => {
return db.insert(users).values(input).returning()
})
})Frontend Usage
'use client'
import { trpc } from '@/lib/trpc'
export function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading } = trpc.user.getById.useQuery({ id: userId })
// user is fully typed: User | undefined
// No manual type annotations needed!
if (isLoading) return <Skeleton />
if (!user) return <NotFound />
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<Badge>{user.role}</Badge> {/* role is 'admin' | 'user' | 'guest' */}
</div>
)
}Zod Schemas: Validation and Types in One
import { z } from 'zod'
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
import { users } from './schema'
// Generate Zod schemas from Drizzle schema
export const insertUserSchema = createInsertSchema(users, {
email: z.string().email('Invalid email format'),
name: z.string().min(2, 'Name too short')
})
export const selectUserSchema = createSelectSchema(users)
// Types derived from schemas
export type InsertUser = z.infer<typeof insertUserSchema>
export type SelectUser = z.infer<typeof selectUserSchema>End-to-End Types in Server Actions
// app/actions.ts
'use server'
import { insertUserSchema } from '@/db/schemas'
export async function createUser(formData: FormData) {
const parsed = insertUserSchema.safeParse({
email: formData.get('email'),
name: formData.get('name'),
})
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors }
}
// parsed.data is fully typed!
const user = await db.insert(users).values(parsed.data).returning()
return { user }
}Catching Type Drift in CI/CD
# .github/workflows/typecheck.yml
steps:
- name: Generate types from DB
run: npm run db:generate
- name: Type check
run: npm run typecheck
- name: Verify no uncommitted type changes
run: git diff --exit-codeConclusion
When your database schema generates your types, and your API preserves those types end-to-end, entire categories of bugs simply cannot exist. The investment in setting this up pays for itself on the first production bug you don't have to debug.



