Full-Stack Type Safety: From Database to Frontend
Back to BlogWeb Development

Full-Stack Type Safety: From Database to Frontend

March 16, 20263 min read6 views

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.$inferInsert

Prisma

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-code

Conclusion

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.

Share this article