Prisma + TypeScript + Zod: Building End-to-End Type Safety
Chapter 1: What is End-to-End Type Safety?
1.1 A Real-World Analogy
Think about ordering food delivery. You place an order on your phone, the restaurant receives it, prepares your food, a driver picks it up, and it arrives at your door. At each step, everyone needs to know:
- What you ordered
- Your delivery address
- Special instructions
Now imagine if each person in this chain kept their own separate list of orders, and these lists weren't synchronized. The restaurant might cook something different than what you ordered. The driver might deliver to the wrong address. Chaos!
This is exactly what happens in software when your database, API, and frontend all maintain separate copies of validation rules. When someone changes the maximum username length from 20 to 30 characters, you have to update three different places. Miss one, and you get bugs.
1.2 Understanding Type Safety
Types are like labels for your data. When you write email: string, you're telling the system "this variable holds text that represents an email address." The system then helps you avoid mistakes like treating an email as a phone number.
Type safety means catching errors before they reach production:
| Level | When | Example |
|---|---|---|
| Compile-time | While writing code | IDE shows red squiggly under email.push() because email is a string, not an array |
| Runtime | When code runs | API rejects malformed data before it reaches your database |
TypeScript handles compile-time checking beautifully. But here's the problem: when data travels across network boundaries (from browser to server), TypeScript's type information disappears. A string becomes generic JSON, and type safety vanishes.
Zod fills this gap. It validates data at runtime and simultaneously generates TypeScript types. It's the bridge between "types I trust at compile time" and "data I can trust at runtime."
1.3 What Makes This Approach Special?
Traditional validation requires maintaining rules in multiple places:
Database: username VARCHAR(20) NOT NULL
API Layer: if (username.length > 20) return error
Frontend: <Input rules={[{ max: 20 }]} />
The Prisma + TypeScript + Zod approach creates a single source of truth:
┌─────────────────────────────────────────────────────────────┐
│ Shared Schema Definition │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ username: z.string().min(1).max(20) │ │
│ │ password: z.string().min(8) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ │ generates │
│ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Prisma │ │ API │ │ Frontend │ │
│ │ Schema │ │ Validator │ │ Forms │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ Database Server User Input │
│ Constraints Logic Feedback │
└─────────────────────────────────────────────────────────────┘
Change once → Everything updates automatically
1.4 The Role of satisfies
The satisfies keyword (added in ES2022) is the secret sauce. It validates that your object matches a type while preserving the object's literal type:
import { z } from 'zod'
// Without satisfies - loses Zod's type inference
const schema = z.object({ name: z.string() })
const myData: z.infer<typeof schema> = { name: 'test' }
// This works, but if schema doesn't have 'name', we'd never know at compile time
// With satisfies - catches missing fields at compile time
const schema = z.object({
name: z.string(),
age: z.number(),
})
// If we forget 'age', TypeScript error: Property 'age' is missing
const user = {
name: 'Alice',
} satisfies z.infer<typeof schema>
Chapter 2: Tool Overview
2.1 Prisma
Prisma is a next-generation ORM (Object-Relational Mapper). Instead of writing SQL queries, you define your database schema in a special DSL, and Prisma generates:
- Type-safe database clients
- Migration scripts
- A type system that mirrors your database structure
model User {
id String @id @default(cuid())
username String @unique
email String?
}
From this, Prisma generates a full TypeScript client with types like User, UserWhereInput, and UserCreateInput.
2.2 Zod
Zod is a "schema validation library." You define what valid data looks like, and Zod:
- Validates data against that definition
- Generates TypeScript types from those definitions
- Provides detailed error messages when validation fails
import { z } from 'zod'
const UserSchema = z.object({
username: z.string().min(1).max(20),
email: z.string().email().optional(),
})
type User = z.infer<typeof UserSchema>
// { username: string; email?: string }
2.3 The Complete Ecosystem
┌─────────────────────────────────────────────────────────────┐
│ Your Code │
├─────────────────────────────────────────────────────────────┤
│ │
│ Prisma Schema Zod Schema TypeScript │
│ (Database) (Validation) (Types) │
│ │ │ │ │
│ └─────────────────────┼───────────────────┘ │
│ │ │
│ ▼ │
│ Shared Definition │
│ (Single Source of Truth) │
│ │
│ │ │
│ ┌───────────────┼───────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ Frontend Backend Database │
│ (Forms) (API) (Prisma) │
│ │
└─────────────────────────────────────────────────────────────┘
Chapter 3: Project Structure
3.1 Recommended Directory Layout
my-app/
├── prisma/
│ └── schema.prisma # Database models
│
├── src/
│ │
│ ├── shared/ # ★ THE MOST IMPORTANT PART ★
│ │ │ # Everything that needs to be
│ │ │ # shared between frontend/backend
│ │ │
│ │ ├── schemas/ # Zod validation schemas
│ │ │ ├── user.schema.ts # User-related rules
│ │ │ └── index.ts # Barrel exports
│ │ │
│ │ ├── generated-schemas/ # Auto-generated by prisma-zod-generator
│ │ │ └── user.schema.ts
│ │ │
│ │ └── types/ # Shared TypeScript types
│ │ └── index.ts
│ │
│ ├── components/ # React components
│ │ ├── RegisterForm.tsx
│ │ └── ui/ # Reusable UI components
│ │
│ ├── app/ # Next.js App Router
│ │ └── api/
│ │ ├── register/
│ │ │ └── route.ts # POST /api/register
│ │ └── login/
│ │ └── route.ts # POST /api/login
│ │
│ └── lib/ # Utilities and helpers
│ ├── db.ts # Prisma client instance
│ ├── auth.ts # Password hashing functions
│ └── validators/
│ └── zod-antd-adapter.ts # Zod to Ant Design converter
│
├── package.json
└── tsconfig.json
3.2 Key File Responsibilities
| File | Purpose | Who Edits This |
|---|---|---|
prisma/schema.prisma | Define database structure | Developer |
shared/schemas/*.ts | Define validation rules | Developer (single edit point) |
lib/validators/* | Convert Zod to UI framework rules | Framework code (rarely changes) |
components/*.tsx | UI forms that use the rules | Developer |
Chapter 4: Step-by-Step Implementation
4.1 Step 1: Define Your Database Model
Open prisma/schema.prisma and define your data structure:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
// Optional: Auto-generate Zod schemas from these definitions
generator zod {
provider = "prisma-zod-generator"
output = "../src/shared/generated-schemas"
// These optimizations reduce generated code size
isGenerateSelect = false // Don't need partial selects for validation
isGenerateInclude = false // Don't need relation includes for validation
}
model User {
// Primary key with auto-generated unique ID
id String @id @default(cuid())
// Username field with constraints
// The @zod comment tells prisma-zod-generator what validation to add
/// @zod.string.min(1, { message: "Username is required" })
/// @zod.string.max(30, { message: "Username too long" })
username String @unique @db.VarChar(30)
// Password field (stores bcrypt hash, not plaintext!)
/// @zod.string.min(8, { message: "Password must be at least 8 characters" })
/// @zod.string.max(128, { message: "Password too long" })
password String @db.VarChar(128)
// Optional email field
/// @zod.string.email({ message: "Invalid email format" }).optional()
email String? @db.VarChar(255)
// Automatic timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Key concepts explained:
-
@id @default(cuid()): Creates a unique ID automatically.cuid()is a format specifically designed for distributed systems. -
@unique: Ensures no duplicate usernames. If you try to insert a duplicate, Prisma throws an error. -
@db.VarChar(30): Database-level type definition. The number (30) is the maximum storage size. -
/// @zod.string...: Prisma's rich comments. These are instructions forprisma-zod-generatorto create matching Zod schemas. -
String?: The question mark means "nullable" - this field can be null in the database.
4.2 Step 2: Create Zod Validation Schemas
Even without auto-generation, you should understand how to write Zod schemas manually:
// src/shared/schemas/user.schema.ts
import { z } from 'zod'
import { type User } from '@prisma/client'
/**
* User validation schema - THE SINGLE SOURCE OF TRUTH
*
* This file defines all validation rules for user data.
* When you change something here, it automatically updates:
* - Frontend form validation
* - Backend API validation
* - Database constraints (via Prisma schema)
*
* Using 'satisfies' ensures this schema stays in sync with the Prisma User model.
* If you add a required field to Prisma but forget it here, TypeScript errors.
*/
export const userSchema = z.object({
username: z
.string()
.min(1, 'Username is required')
.max(30, 'Username must be 30 characters or less')
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.max(128, 'Password is too long'),
email: z
.string()
.email('Please enter a valid email address')
.optional()
.or(z.literal('')), // Also accept empty string (converts to undefined)
})
satisfies z.ZodType<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>
/**
* Type inference: Extract TypeScript type from the schema
* This type is safe to use everywhere because it comes from validated data
*/
export type UserInput = z.infer<typeof userSchema>
/**
* Separate schema for login (doesn't need email)
*/
export const loginSchema = z.object({
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
})
export type LoginInput = z.infer<typeof loginSchema>
/**
* Password update schema (requires old password for security)
*/
export const passwordUpdateSchema = z.object({
oldPassword: z.string().min(1, 'Current password is required'),
newPassword: z
.string()
.min(8, 'New password must be at least 8 characters')
.max(128, 'Password is too long'),
})
export type PasswordUpdateInput = z.infer<typeof passwordUpdateSchema>
4.3 Step 3: Create the Zod-to-Ant Design Adapter
Ant Design uses a different validation format than Zod. This adapter bridges the gap:
// src/lib/validators/zod-antd-adapter.ts
import { type ZodObject, type ZodType, type ZodOptional, type ZodDefault } from 'zod'
/**
* Result type for a single Ant Design Form.Item rule
*/
export interface AntdValidatorRule {
validator: (rule: unknown, value: unknown) => Promise<void>
message?: string
required?: boolean
type?: string
}
/**
* Convert a Zod schema into Ant Design Form rules
*
* Usage:
* const rules = createZodFormValidator(userSchema)
* <Form.Item name="username" rules={rules.username} />
*
* Benefits:
* - Write validation logic once (in shared/schemas)
* - Use it everywhere (frontend, backend, tests)
* - Automatic error message formatting
*/
export const createZodFormValidator = <T extends Record<string, unknown>>(
schema: ZodObject<T>,
): Record<keyof T, AntdValidatorRule[]> => {
const rules = {} as Record<keyof T, AntdValidatorRule[]>
// Iterate over each field in the schema
for (const key in schema.shape) {
const fieldSchema = schema.shape[key as keyof T]
rules[key as keyof T] = [createSingleFieldValidator(fieldSchema, key)]
}
return rules
}
/**
* Create a validator function for a single field
*/
const createSingleFieldValidator = (fieldSchema: ZodType<unknown>, fieldName: string): AntdValidatorRule => {
return {
validator: async (_rule: unknown, value: unknown) => {
// Handle empty values
if (value === undefined || value === null || value === '') {
// Check if field is optional by examining the schema structure
if (isOptionalSchema(fieldSchema)) {
return Promise.resolve()
}
return Promise.reject(new Error('This field is required'))
}
// Run Zod validation asynchronously
const result = await fieldSchema.safeParseAsync(value)
if (!result.success) {
// Get the first error message
const issue = result.error.issues[0]
const message = formatErrorMessage(issue, fieldName)
return Promise.reject(new Error(message))
}
return Promise.resolve()
},
}
}
/**
* Recursively check if a schema represents an optional field
*/
const isOptionalSchema = (schema: ZodType<unknown>): boolean => {
const def = (schema as unknown as { _def: { innerType?: ZodType } })._def
// ZodOptional wraps the actual schema in innerType
if (def.innerType) {
return isOptionalSchema(def.innerType)
}
// ZodDefault means there's a default value
if ((schema as unknown as { _def: { defaultValue?: unknown } })._def.defaultValue !== undefined) {
return true
}
return false
}
/**
* Convert Zod error codes into user-friendly messages
*/
const formatErrorMessage = (
issue: { code: string; message: string; path: (string | number)[] },
fieldName: string,
): string => {
// Human-readable field names
const fieldLabels: Record<string, string> = {
username: 'Username',
password: 'Password',
email: 'Email',
confirmPassword: 'Password confirmation',
}
const label = fieldLabels[fieldName] || fieldName
switch (issue.code) {
case 'too_small':
if (issue.type === 'string') {
return `${label} must be at least ${issue.minimum} characters`
}
return `${label} is required`
case 'too_big':
if (issue.type === 'string') {
return `${label} must be ${issue.maximum} characters or less`
}
return `${label} is too large`
case 'invalid_string':
if (issue.validation === 'email') {
return 'Please enter a valid email address'
}
if (issue.validation === 'regex') {
return `${label} format is invalid`
}
return `${label} format is invalid`
case 'invalid_type':
return `${label} has the wrong type`
default:
return issue.message || `${label} validation failed`
}
}
4.4 Step 4: Build the Frontend Form
Now the magic happens: your frontend form automatically inherits all validation rules:
// src/components/RegisterForm.tsx
import { useState } from 'react'
import { Form, Input, Button, Card, message, Typography } from 'antd'
import { UserOutlined, LockOutlined, MailOutlined } from '@ant-design/icons'
import { userSchema, type UserInput } from '@/shared/schemas/user.schema'
import { createZodFormValidator } from '@/lib/validators/zod-antd-adapter'
const { Title } = Typography
// Generate form rules ONCE, use them everywhere
const formRules = createZodFormValidator(userSchema)
export default function RegisterForm() {
const [form] = Form.useForm<UserInput>()
const [loading, setLoading] = useState(false)
const onFinish = async (values: UserInput) => {
setLoading(true)
try {
// Send to backend API
const response = await fetch('/api/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
})
const data = await response.json()
if (!response.ok) {
// Handle server-side validation errors
if (data.details) {
// Map server errors back to form fields
const fieldErrors = Object.entries(data.details).map(([name, errors]) => ({
name: name as keyof UserInput,
errors: errors as string[],
}))
form.setFields(fieldErrors)
} else {
message.error(data.error || 'Registration failed')
}
return
}
message.success('Registration successful!')
form.resetFields()
} catch (error) {
message.error('Network error. Please try again.')
} finally {
setLoading(false)
}
}
return (
<Card
style={{ width: 400 }}
bordered
title={
<Title level={4} style={{ margin: 0 }}>
Create Account
</Title>
}
>
<Form
form={form}
layout="vertical"
onFinish={onFinish}
autoComplete="off"
initialValues={{
email: '',
}}
>
<Form.Item
label="Username"
name="username"
rules={formRules.username}
extra="Letters, numbers, and underscores only. Max 30 characters."
>
<Input prefix={<UserOutlined />} placeholder="Choose a username" maxLength={30} />
</Form.Item>
<Form.Item label="Password" name="password" rules={formRules.password} extra="At least 8 characters">
<Input.Password prefix={<LockOutlined />} placeholder="Create a password" />
</Form.Item>
<Form.Item label="Email (Optional)" name="email" rules={formRules.email}>
<Input prefix={<MailOutlined />} placeholder="your@email.com" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={loading} block size="large">
Register
</Button>
</Form.Item>
</Form>
</Card>
)
}
4.5 Step 5: Implement the Backend API
The backend is your last line of defense. Even if frontend validation passes, you must validate again:
// src/app/api/register/route.ts
import { NextResponse } from 'next/server'
import { userSchema } from '@/shared/schemas/user.schema'
import { db } from '@/lib/db'
import { hashPassword } from '@/lib/auth'
export async function POST(request: Request) {
try {
// Step 1: Parse the incoming JSON
const json = await request.json()
// Step 2: Runtime validation with Zod
// safeParse returns { success: true, data } or { success: false, error }
// Unlike parse(), it doesn't throw exceptions
const result = userSchema.safeParse(json)
if (!result.success) {
// Return validation errors to client
return NextResponse.json(
{
error: 'Validation failed',
details: result.error.flatten().fieldErrors,
},
{ status: 400 },
)
}
// Step 3: Destructure validated data
// TypeScript now knows exactly what this object contains
const { username, password, email } = result.data
// Step 4: Check if username already exists
const existingUser = await db.user.findUnique({
where: { username },
select: { id: true }, // Only fetch what we need
})
if (existingUser) {
return NextResponse.json({ error: 'Username already taken' }, { status: 409 })
}
// Step 5: Hash the password (NEVER store plaintext!)
const hashedPassword = await hashPassword(password)
// Step 6: Create the user in database
const newUser = await db.user.create({
data: {
username,
password: hashedPassword,
email: email || null,
},
select: {
id: true,
username: true,
createdAt: true,
},
})
// Step 7: Return success response
return NextResponse.json(
{
message: 'Registration successful',
user: {
id: newUser.id,
username: newUser.username,
},
},
{ status: 201 },
)
} catch (error) {
// Log the actual error (use proper logging in production)
console.error('Registration error:', error)
// Handle specific error types
if (error instanceof Error) {
// Prisma unique constraint violation
if (error.message.includes('Unique constraint')) {
return NextResponse.json({ error: 'Username already taken' }, { status: 409 })
}
}
// Generic error response (don't leak internal details)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
4.6 Step 6: Password Hashing Utilities
Never, ever store passwords in plain text. Always hash them:
// src/lib/auth.ts
import { compare, hash } from 'bcrypt'
const SALT_ROUNDS = 12 // Higher = more secure but slower
/**
* Hash a plain text password
*
* Why bcrypt?
* - Designed specifically for password hashing
* - Includes salt automatically (prevents rainbow table attacks)
* - Configurable work factor (can increase as computers get faster)
*/
export const hashPassword = async (password: string): Promise<string> => {
return hash(password, SALT_ROUNDS)
}
/**
* Verify a password against a hash
*
* Returns true if password matches, false otherwise.
* Timing-safe comparison prevents timing attacks.
*/
export const comparePassword = async (plainPassword: string, hashedPassword: string): Promise<boolean> => {
return compare(plainPassword, hashedPassword)
}
Chapter 5: Automating with prisma-zod-generator
5.1 Why Auto-Generate?
Manual schemas work great, but they require you to manually sync with Prisma schema changes. What if you could define validation rules right next to your database fields?
prisma-zod-generator does exactly that. It reads your schema.prisma file and generates matching Zod schemas automatically.
5.2 Installation
# Install the generator
pnpm add -D prisma-zod-generator
# Install bcrypt for password hashing
pnpm add bcrypt
pnpm add -D @types/bcrypt
5.3 Configure schema.prisma
generator client {
provider = "prisma-client-js"
}
// Add the Zod generator
generator zod {
provider = "prisma-zod-generator"
output = "../src/shared/generated-schemas"
// Performance optimizations
isGenerateSelect = false // Don't need partial User types for validation
isGenerateInclude = false // Don't need relation types for validation
}
// Now add validation rules directly in comments!
model User {
id String @id @default(cuid())
/// @zod.string.min(1, { message: "Username is required" })
/// @zod.string.max(30, { message: "Username must be 30 characters or less" })
/// @zod.string.regex(/^[a-zA-Z0-9_]+$/, { message: "Only letters, numbers, underscores" })
username String @unique @db.VarChar(30)
/// @zod.string.min(8, { message: "Password must be at least 8 characters" })
/// @zod.string.max(128)
password String @db.VarChar(128)
/// @zod.string.email({ message: "Please enter a valid email" }).optional()
email String? @db.VarChar(255)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
5.4 Run Generation
npx prisma generate
This creates files in src/shared/generated-schemas/:
// Generated output (simplified)
import { z } from 'zod'
export const UserModel = z.object({
id: z.string().cuid(),
username: z
.string()
.min(1, { message: 'Username is required' })
.max(30, { message: 'Username must be 30 characters or less' })
.regex(/^[a-zA-Z0-9_]+$/, { message: 'Only letters, numbers, underscores' }),
password: z.string().min(8, { message: 'Password must be at least 8 characters' }).max(128),
email: z.string().email({ message: 'Please enter a valid email' }).optional(),
createdAt: z.date(),
updatedAt: z.date(),
})
export type User = z.infer<typeof UserModel>
5.5 Using Generated Schemas
import { UserModel } from '@/shared/generated-schemas/User.schema'
import { createZodFormValidator } from '@/lib/validators/zod-antd-adapter'
import { z } from 'zod'
// Remove auto-generated fields (id, timestamps) for forms
const RegisterFormSchema = UserModel.omit({
id: true,
createdAt: true,
updatedAt: true,
})
// Add frontend-specific validations
const RegisterFormSchemaWithConfirm = RegisterFormSchema.extend({
confirmPassword: z.string().min(1, 'Please confirm your password'),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
})
// Extract TypeScript type
export type RegisterFormData = z.infer<typeof RegisterFormSchema>
// Generate Ant Design rules
const formRules = createZodFormValidator(RegisterFormSchema)
Chapter 6: Architecture Diagrams
6.1 Data Flow Diagram
┌─────────────────────────────────────────────────────────────────┐
│ USER ACTION │
│ ┌──────────────┐ │
│ │ Types username│ │
│ │ Types password│ │
│ │ Types email │ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ FRONTEND LAYER │ │
│ │ │ │
│ │ userSchema ──→ createZodFormValidator() ──→ Form Rules │ │
│ │ │ │
│ │ • Real-time validation as user types │ │
│ │ • Friendly error messages │ │
│ │ • Required field indicators (red asterisks) │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ HTTP POST /api/register │
│ │ Content-Type: application/json │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ BACKEND LAYER │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ Zod Validation (Runtime) │ │ │
│ │ │ │ │ │
│ │ │ userSchema.safeParse(requestBody) │ │ │
│ │ │ │ │ │
│ │ │ • Rejects invalid JSON │ │ │
│ │ │ • Returns structured errors │ │ │
│ │ │ • Strips unexpected fields │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ Business Logic │ │ │
│ │ │ │ │ │
│ │ │ • Check username uniqueness │ │ │
│ │ │ • Hash password │ │ │
│ │ │ • Prepare database operation │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ Prisma Database Client │ │ │
│ │ │ │ │ │
│ │ │ db.user.create({ data: userData }) │ │ │
│ │ │ │ │ │
│ │ │ • Database constraints enforced │ │ │
│ │ │ • Transactions if needed │ │ │
│ │ │ • Returns created record │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ SQL INSERT │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ DATABASE LAYER │ │
│ │ │ │
│ │ CREATE TABLE users ( │ │
│ │ id VARCHAR(25) PRIMARY KEY, │ │
│ │ username VARCHAR(30) UNIQUE NOT NULL, │ │
│ │ password VARCHAR(128) NOT NULL, │ │
│ │ email VARCHAR(255), │ │
│ │ created_at TIMESTAMP DEFAULT NOW(), │ │
│ │ updated_at TIMESTAMP DEFAULT NOW() │ │
│ │ ) │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
6.2 Type Propagation Diagram
┌──────────────────────────────────────────────────────────────────┐
│ TYPE PROPAGATION FLOW │
│ │
│ ┌────────────────┐ │
│ │ PRISMA │ │
│ │ SCHEMA │ │
│ │ │ │
│ │ model User { │ │
│ │ username │ │
│ │ String │ │
│ │ } │ │
│ └───────┬────────┘ │
│ │ │
│ │ prisma generate │
│ │ │
│ ▼ │
│ ┌────────────────┐ TypeScript compiles │
│ │ Prisma Types │ ────────────────────────────────────────────── │
│ │ │ │
│ │ type User = { │ │
│ │ id: string │ │
│ │ username: │ │
│ │ string │ │
│ │ } │ │
│ └───────┬────────┘ │
│ │ │
│ │ Manual or auto-generated │
│ │ │
│ ▼ │
│ ┌────────────────┐ │
│ │ ZOD SCHEMA │ │
│ │ │ │
│ │ const schema = │ │
│ │ z.object({ │ │
│ │ username: │ │
│ │ z.string() │ │
│ │ }) │ │
│ │ │ │
│ │ satisfies │ │
│ │ ZodType<User> │ ← Ensures schema matches Prisma type │
│ └───────┬────────┘ │
│ │ │
│ │ z.infer<typeof schema> │
│ │ │
│ ▼ │
│ ┌────────────────┐ │
│ │ FORM INPUT │ │
│ │ TYPE │ │
│ │ │ │
│ │ type UserInput │ │
│ │ = { │ │
│ │ username: │ │
│ │ string │ │
│ │ } │ │
│ └───────┬────────┘ │
│ │ │
│ │ Used in │
│ ├────────────────────────────────────────────────────── │
│ │ │
│ ▼ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ FRONTEND FORM │ │ BACKEND API │ │ TEST SUITE │ │
│ │ │ │ │ │ │ │
│ │ Form.useForm< │ │ const data = │ │ expect(valid │ │
│ │ UserInput>() │ │ result.data │ │ data).toBe │ │
│ │ │ │ │ │ valid │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ │
│ │
│ ✓ Change once → Updates everywhere │
└──────────────────────────────────────────────────────────────────┘
Chapter 7: Frequently Asked Questions
Q1: Why not use GraphQL instead?
GraphQL is excellent for complex data requirements. However, for typical CRUD applications, REST + Prisma + Zod offers advantages:
| Aspect | GraphQL | REST + Prisma + Zod |
|---|---|---|
| Setup complexity | Higher (schema, resolvers, codegen) | Lower |
| Performance | Query parsing overhead | Faster (direct SQL) |
| Validation control | Limited | Full control |
| Best for | Complex nested queries | Standard forms and APIs |
Choose GraphQL if you need flexible queries and are comfortable with the added complexity.
Q2: How do I validate nested objects?
Zod handles nesting naturally:
const addressSchema = z.object({
city: z.string().min(1, 'City is required'),
street: z.string().min(1, 'Street is required'),
zipCode: z.string().regex(/^\d{6}$/, 'Invalid zip code'),
})
const userWithAddressSchema = z.object({
username: z.string().min(1),
address: addressSchema, // Nested!
})
// The adapter handles nested structures automatically
const rules = createZodFormValidator(userWithAddressSchema)
// rules.address.city, rules.address.street, etc.
Q3: How do I customize error messages?
Three approaches:
Option 1: Inline in Zod
z.string().min(1, 'Custom required message')
Option 2: In Prisma comments
/// @zod.string.min(8, { message: "Password too short" })
Option 3: In the adapter
const formatErrorMessage = (issue, fieldName) => {
const messages: Record<string, string> = {
'username.too_small': 'Username must be at least 1 character',
'password.too_small': 'Password must be at least 8 characters',
}
return messages[`${fieldName}.${issue.code}`] || issue.message
}
Q4: How do I add custom validation rules?
// Username must contain at least one letter
const userSchema = z.object({
username: z
.string()
.min(1)
.max(30)
.refine((val) => /[a-zA-Z]/.test(val), { message: 'Username must contain at least one letter' }),
})
// Password strength requirements
const strongPassword = z
.string()
.min(8)
.refine((val) => /[A-Z]/.test(val) && /[a-z]/.test(val) && /[0-9]/.test(val), {
message: 'Password must contain uppercase, lowercase, and numbers',
})
Q5: How do I write tests for validation?
// __tests__/user-schema.test.ts
import { userSchema, loginSchema } from '@/shared/schemas/user.schema'
describe('User schema validation', () => {
describe('userSchema', () => {
it('accepts valid data', () => {
const validData = {
username: 'testuser',
password: 'password123',
email: 'test@example.com',
}
const result = userSchema.safeParse(validData)
expect(result.success).toBe(true)
})
it('rejects empty username', () => {
const invalidData = {
username: '',
password: 'password123',
}
const result = userSchema.safeParse(invalidData)
expect(result.success).toBe(false)
expect(result.error?.issues[0].path).toEqual(['username'])
})
it('rejects invalid email', () => {
const invalidData = {
username: 'testuser',
password: 'password123',
email: 'not-an-email',
}
const result = userSchema.safeParse(invalidData)
expect(result.success).toBe(false)
expect(result.error?.issues[0].path).toEqual(['email'])
})
it('accepts missing optional email', () => {
const validData = {
username: 'testuser',
password: 'password123',
}
const result = userSchema.safeParse(validData)
expect(result.success).toBe(true)
})
})
describe('loginSchema', () => {
it('only requires username and password', () => {
const validData = {
username: 'testuser',
password: 'password123',
}
const result = loginSchema.safeParse(validData)
expect(result.success).toBe(true)
})
it('rejects email field', () => {
const invalidData = {
username: 'testuser',
password: 'password123',
email: 'test@example.com',
}
const result = loginSchema.safeParse(invalidData)
expect(result.success).toBe(false)
})
})
})
Chapter 8: Best Practices and Checklist
8.1 Architecture Principles
-
Single Source of Truth: Define validation rules once, in
shared/schemas/ -
Trust But Verify: Frontend validation is for UX. Backend validation is for security. Do both.
-
Defense in Depth: Database constraints + API validation + Frontend validation = secure
-
Type-First Development: Define types before writing implementation
8.2 Security Checklist
Before deploying, verify:
- Passwords are hashed with bcrypt/argon2 before storage
- All user input is validated with Zod on the server
- Error messages don't expose sensitive information
- HTTPS is enabled
- Rate limiting prevents brute force attacks
- File uploads validate type and size
- SQL injection is prevented (Prisma handles this, but don't concatenate SQL strings!)
8.3 Performance Tips
- Reduce generated code:
isGenerateSelect = false
isGenerateInclude = false
-
Use async validation for non-blocking operations
-
Cache schema objects - they're immutable and reusable
8.4 Maintenance Tips
-
Run
prisma generateregularly to keep schemas in sync -
Version control your schema changes
-
Write tests for validation rules
-
Document complex validation logic with comments
Conclusion
End-to-end type safety isn't just about eliminating red squiggly lines in your IDE. It's about building a system where a single change propagates automatically through all layers. When you modify a field constraint in Prisma schema, your frontend forms update, your API validation updates, and your tests update - all from one change.
This isn't magic. It's the result of carefully designing your architecture to have a single source of truth, and using tools that respect that design.
The next time someone asks you to change "username from 20 to 30 characters," you can make that change in one file, run your tests, and deploy with confidence.
Comments
No comments yet. Be the first to share your thoughts!