Files
2025-11-29 18:22:25 +08:00

10 KiB

name, description, allowed-tools
name description allowed-tools
validating-query-inputs Validate all external input with Zod before Prisma operations. Use when accepting user input, API requests, or form data. Read, Write, Edit

Input Validation with Zod and Prisma 6

Overview

Always validate external input with Zod before Prisma operations. Never trust user-provided data, API requests, or form submissions. Use type-safe validation pipelines that match Prisma schema types.

Validation Pipeline

External Input → Zod Validation → Transform → Prisma Operation

Pattern

import { z } from 'zod'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

const createUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(100),
  age: z.number().int().positive().optional()
})

async function createUser(rawInput: unknown) {
  const validatedData = createUserSchema.parse(rawInput)

  return await prisma.user.create({
    data: validatedData
  })
}

Zod Schemas for Prisma Models

Matching Prisma Types

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String
  phone     String?
  website   String?
  age       Int?
  createdAt DateTime @default(now())
}
import { z } from 'zod'

const phoneRegex = /^\+?[1-9]\d{1,14}$/

const userCreateSchema = z.object({
  email: z.string().email().toLowerCase(),
  name: z.string().min(1).max(100).trim(),
  phone: z.string().regex(phoneRegex).optional(),
  website: z.string().url().optional(),
  age: z.number().int().min(0).max(150).optional()
})

const userUpdateSchema = userCreateSchema.partial()

type UserCreateInput = z.infer<typeof userCreateSchema>
type UserUpdateInput = z.infer<typeof userUpdateSchema>

Common Validation Patterns

const emailSchema = z.string().email().toLowerCase().trim()

const urlSchema = z.string().url().refine(
  (url) => url.startsWith('https://'),
  { message: 'URL must use HTTPS' }
)

const phoneSchema = z.string().regex(
  /^\+?[1-9]\d{1,14}$/,
  'Invalid phone number format'
)

const slugSchema = z.string()
  .min(1)
  .max(100)
  .regex(/^[a-z0-9-]+$/, 'Slug must contain only lowercase letters, numbers, and hyphens')

const dateSchema = z.coerce.date().refine(
  (date) => date > new Date(),
  { message: 'Date must be in the future' }
)

Complete Validation Examples

API Route with Validation

import { z } from 'zod'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

const createPostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
  authorId: z.string().cuid(),
  published: z.boolean().default(false),
  tags: z.array(z.string()).max(10).optional()
})

export async function POST(request: Request) {
  try {
    const rawBody = await request.json()
    const validatedData = createPostSchema.parse(rawBody)

    const post = await prisma.post.create({
      data: validatedData
    })

    return Response.json(post)
  } catch (error) {
    if (error instanceof z.ZodError) {
      return Response.json(
        { errors: error.errors },
        { status: 400 }
      )
    }
    throw error
  }
}

Form Data Validation

import { z } from 'zod'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

const profileUpdateSchema = z.object({
  name: z.string().min(1).max(100).trim(),
  bio: z.string().max(500).trim().optional(),
  website: z.string().url().optional().or(z.literal('')),
  location: z.string().max(100).trim().optional(),
  birthDate: z.coerce.date().max(new Date()).optional()
})

async function updateProfile(userId: string, formData: FormData) {
  const rawData = {
    name: formData.get('name'),
    bio: formData.get('bio'),
    website: formData.get('website'),
    location: formData.get('location'),
    birthDate: formData.get('birthDate')
  }

  const validatedData = profileUpdateSchema.parse(rawData)

  return await prisma.user.update({
    where: { id: userId },
    data: validatedData
  })
}

Nested Object Validation

import { z } from 'zod'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

const addressSchema = z.object({
  street: z.string().min(1).max(200),
  city: z.string().min(1).max(100),
  state: z.string().length(2).toUpperCase(),
  zipCode: z.string().regex(/^\d{5}(-\d{4})?$/)
})

const createCompanySchema = z.object({
  name: z.string().min(1).max(200),
  email: z.string().email().toLowerCase(),
  website: z.string().url().optional(),
  address: addressSchema
})

async function createCompany(rawInput: unknown) {
  const validatedData = createCompanySchema.parse(rawInput)

  return await prisma.company.create({
    data: {
      name: validatedData.name,
      email: validatedData.email,
      website: validatedData.website,
      address: {
        create: validatedData.address
      }
    },
    include: {
      address: true
    }
  })
}

Bulk Operation Validation

import { z } from 'zod'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

const bulkUserSchema = z.object({
  users: z.array(
    z.object({
      email: z.string().email().toLowerCase(),
      name: z.string().min(1).max(100),
      role: z.enum(['USER', 'ADMIN'])
    })
  ).min(1).max(100)
})

async function createBulkUsers(rawInput: unknown) {
  const validatedData = bulkUserSchema.parse(rawInput)

  const uniqueEmails = new Set(validatedData.users.map(u => u.email))
  if (uniqueEmails.size !== validatedData.users.length) {
    throw new Error('Duplicate emails in bulk operation')
  }

  return await prisma.$transaction(
    validatedData.users.map(user =>
      prisma.user.create({ data: user })
    )
  )
}

Advanced Patterns

Custom Refinements

import { z } from 'zod'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

const passwordSchema = z.string()
  .min(8)
  .refine((pwd) => /[A-Z]/.test(pwd), {
    message: 'Password must contain uppercase letter'
  })
  .refine((pwd) => /[a-z]/.test(pwd), {
    message: 'Password must contain lowercase letter'
  })
  .refine((pwd) => /[0-9]/.test(pwd), {
    message: 'Password must contain number'
  })

const registerSchema = z.object({
  email: z.string().email().toLowerCase(),
  password: passwordSchema,
  confirmPassword: z.string()
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword']
})

Async Validation

import { z } from 'zod'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

const createUserSchema = z.object({
  email: z.string().email().toLowerCase(),
  username: z.string().min(3).max(30).regex(/^[a-z0-9_]+$/)
})

async function createUserWithChecks(rawInput: unknown) {
  const validatedData = createUserSchema.parse(rawInput)

  const existing = await prisma.user.findFirst({
    where: {
      OR: [
        { email: validatedData.email },
        { username: validatedData.username }
      ]
    }
  })

  if (existing) {
    if (existing.email === validatedData.email) {
      throw new Error('Email already exists')
    }
    if (existing.username === validatedData.username) {
      throw new Error('Username already taken')
    }
  }

  return await prisma.user.create({
    data: validatedData
  })
}

Safe Parsing

import { z } from 'zod'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

const updateSettingsSchema = z.object({
  theme: z.enum(['light', 'dark']).default('light'),
  notifications: z.boolean().default(true),
  language: z.string().length(2).default('en')
})

async function updateSettings(userId: string, rawInput: unknown) {
  const result = updateSettingsSchema.safeParse(rawInput)

  if (!result.success) {
    return {
      success: false,
      errors: result.error.errors
    }
  }

  const settings = await prisma.userSettings.upsert({
    where: { userId },
    update: result.data,
    create: {
      userId,
      ...result.data
    }
  })

  return {
    success: true,
    data: settings
  }
}

Security Checklist

  • All external input validated before Prisma operations
  • Zod schemas match Prisma model types
  • Email addresses normalized (toLowerCase)
  • String inputs trimmed where appropriate
  • URLs validated and HTTPS enforced
  • Phone numbers validated with regex
  • Numeric ranges validated (min/max)
  • Array lengths limited (prevent DoS)
  • Unique constraints validated before bulk operations
  • Async existence checks for unique fields
  • Error messages don't leak sensitive data
  • File uploads validated (type, size, content)

Anti-Patterns

Never Trust Input

async function createUser(data: any) {
  return await prisma.user.create({ data })
}

Never Skip Validation for "Internal" Data

async function createUserFromAdmin(data: unknown) {
  return await prisma.user.create({ data })
}

Never Validate After Database Operation

async function createUser(data: unknown) {
  const user = await prisma.user.create({ data })
  const validated = schema.parse(user)
  return validated
}

Type Safety Integration

import { z } from 'zod'
import { Prisma } from '@prisma/client'

const userCreateSchema = z.object({
  email: z.string().email(),
  name: z.string()
}) satisfies z.Schema<Prisma.UserCreateInput>

type ValidatedUserInput = z.infer<typeof userCreateSchema>

Zod v4 Validation:

  • If normalizing string inputs (trim, toLowerCase), use the transforming-string-methods skill for Zod v4 built-in string transformations
  • If using Zod for schema construction, use the validating-schema-basics skill from zod-4 for core validation patterns
  • If customizing validation error messages, use the customizing-errors skill from zod-4 for error formatting strategies
  • If validating string formats (email, UUID, URL), use the validating-string-formats skill from zod-4 for built-in validators

TypeScript Validation:

  • If performing runtime type checking beyond Zod, use the using-runtime-checks skill from typescript for assertion patterns
  • If validating external data sources, use the validating-external-data skill from typescript for comprehensive validation strategies