搜索
Back to Posts

Prisma + TypeScript + Zod: Building End-to-End Type Safety

25 min read0Max ZhangBackend
TypeScriptPrisma

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:

LevelWhenExample
Compile-timeWhile writing codeIDE shows red squiggly under email.push() because email is a string, not an array
RuntimeWhen code runsAPI 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

FilePurposeWho Edits This
prisma/schema.prismaDefine database structureDeveloper
shared/schemas/*.tsDefine validation rulesDeveloper (single edit point)
lib/validators/*Convert Zod to UI framework rulesFramework code (rarely changes)
components/*.tsxUI forms that use the rulesDeveloper

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:

  1. @id @default(cuid()): Creates a unique ID automatically. cuid() is a format specifically designed for distributed systems.

  2. @unique: Ensures no duplicate usernames. If you try to insert a duplicate, Prisma throws an error.

  3. @db.VarChar(30): Database-level type definition. The number (30) is the maximum storage size.

  4. /// @zod.string...: Prisma's rich comments. These are instructions for prisma-zod-generator to create matching Zod schemas.

  5. 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:

AspectGraphQLREST + Prisma + Zod
Setup complexityHigher (schema, resolvers, codegen)Lower
PerformanceQuery parsing overheadFaster (direct SQL)
Validation controlLimitedFull control
Best forComplex nested queriesStandard 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

  1. Single Source of Truth: Define validation rules once, in shared/schemas/

  2. Trust But Verify: Frontend validation is for UX. Backend validation is for security. Do both.

  3. Defense in Depth: Database constraints + API validation + Frontend validation = secure

  4. 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

  1. Reduce generated code:
isGenerateSelect  = false
isGenerateInclude = false
  1. Use async validation for non-blocking operations

  2. Cache schema objects - they're immutable and reusable

8.4 Maintenance Tips

  1. Run prisma generate regularly to keep schemas in sync

  2. Version control your schema changes

  3. Write tests for validation rules

  4. 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

0/1000

No comments yet. Be the first to share your thoughts!