Zod 4 Best Practices
This document outlines best practices for using Zod 4 in Astro and Vue projects, focusing on schema definitions, parsing, transformations, error handling, and form integration.
Migration Guide from Zod 3 to Zod 4
typescript1// ⚠️ Deprecated (still works in v4, will be removed in v5) 2z.string().email() 3z.string().uuid() 4z.string().url() 5z.string().nonempty() 6z.string().min(5, { message: "Too short" }) 7 8// ✅ Recommended (better performance, tree-shaking) 9z.email() 10z.uuid() 11z.url() 12z.string().min(1) 13z.string().min(5, { error: "Too short" }) 14 15// Note: Old patterns still work in v4 for backward compatibility
Error Message Parameters
typescript1// Both work in Zod 4, but 'error' is preferred 2z.string().min(5, { message: "Too short" }) // ⚠️ Deprecated 3z.string().min(5, { error: "Too short" }) // ✅ Preferred 4 5// Error can be a function for dynamic messages 6z.string({ 7 error: (issue) => issue.input === undefined 8 ? "Field is required" 9 : "Invalid string" 10})
Basic Schemas
typescript1import { z } from "zod"; 2 3// Primitives 4const stringSchema = z.string(); 5const numberSchema = z.number(); 6const booleanSchema = z.boolean(); 7const dateSchema = z.date(); 8 9// Top-level validators (Zod 4) 10const emailSchema = z.email(); 11const uuidSchema = z.uuid(); 12const urlSchema = z.url(); 13 14// With constraints 15const nameSchema = z.string().min(1).max(100); 16const ageSchema = z.number().int().positive().max(150); 17const priceSchema = z.number().min(0).multipleOf(0.01);
Object Schemas
typescript1const userSchema = z.object({ 2 id: z.uuid(), 3 email: z.email({ error: "Invalid email address" }), 4 name: z.string().min(1, { error: "Name is required" }), 5 age: z.number().int().positive().optional(), 6 role: z.enum(["admin", "user", "guest"]), 7 metadata: z.record(z.string(), z.unknown()).optional(), 8}); 9 10type User = z.infer<typeof userSchema>; 11 12// Parsing 13const user = userSchema.parse(data); // Throws on error 14const result = userSchema.safeParse(data); // Returns { success, data/error } 15 16if (result.success) { 17 console.log(result.data); 18} else { 19 console.log(result.error.issues); 20}
Arrays and Records
typescript1// Arrays 2const tagsSchema = z.array(z.string()).min(1).max(10); 3const numbersSchema = z.array(z.number()).min(1); 4 5// Records (objects with dynamic keys) 6const scoresSchema = z.record(z.string(), z.number()); 7// { [key: string]: number } 8 9// Tuples 10const coordinatesSchema = z.tuple([z.number(), z.number()]); 11// [number, number]
Unions and Discriminated Unions
typescript1// Simple union 2const stringOrNumber = z.union([z.string(), z.number()]); 3 4// Discriminated union (more efficient) 5const resultSchema = z.discriminatedUnion("status", [ 6 z.object({ status: z.literal("success"), data: z.unknown() }), 7 z.object({ status: z.literal("error"), error: z.string() }), 8]);
Transformations
typescript1// Transform during parsing 2const lowercaseEmail = z.email().transform(email => email.toLowerCase()); 3 4// Coercion (convert types) 5const numberFromString = z.coerce.number(); // "42" → 42 6const dateFromString = z.coerce.date(); // "2024-01-01" → Date 7 8// Preprocessing 9const trimmedString = z.preprocess( 10 val => typeof val === "string" ? val.trim() : val, 11 z.string() 12);
Refinements
typescript1const passwordSchema = z.string() 2 .min(8) 3 .refine(val => /[A-Z]/.test(val), { 4 message: "Must contain uppercase letter", 5 }) 6 .refine(val => /[0-9]/.test(val), { 7 message: "Must contain number", 8 }); 9 10// With superRefine for multiple errors 11const formSchema = z.object({ 12 password: z.string(), 13 confirmPassword: z.string(), 14}).superRefine((data, ctx) => { 15 if (data.password !== data.confirmPassword) { 16 ctx.addIssue({ 17 code: z.ZodIssueCode.custom, 18 message: "Passwords don't match", 19 path: ["confirmPassword"], 20 }); 21 } 22});
Optional and Nullable
typescript1// Optional (T | undefined) 2z.string().optional() 3 4// Nullable (T | null) 5z.string().nullable() 6 7// Both (T | null | undefined) 8z.string().nullish() 9 10// Default values 11z.string().default("unknown") 12z.number().default(() => Math.random())
Error Handling
typescript1// Zod 4: Use 'error' param instead of 'message' 2const schema = z.object({ 3 name: z.string({ error: "Name must be a string" }), 4 email: z.email({ error: "Invalid email format" }), 5 age: z.number().min(18, { error: "Must be 18 or older" }), 6}); 7 8// Custom error map 9const customSchema = z.string({ 10 error: (issue) => { 11 if (issue.code === "too_small") { 12 return "String is too short"; 13 } 14 return "Invalid string"; 15 }, 16});
Form Validation in Vue
vue1<script setup lang="ts"> 2import { ref, computed } from 'vue'; 3import { z } from 'zod'; 4 5const loginSchema = z.object({ 6 email: z.email({ error: 'Invalid email address' }), 7 password: z.string().min(8, { error: 'Password must be at least 8 characters' }), 8}); 9 10type LoginForm = z.infer<typeof loginSchema>; 11 12const formData = ref<Partial<LoginForm>>({ 13 email: '', 14 password: '', 15}); 16 17const errors = ref<Partial<Record<keyof LoginForm, string>>>({}); 18 19const validateField = (field: keyof LoginForm) => { 20 const schema = loginSchema.pick({ [field]: true }); 21 const result = schema.safeParse({ [field]: formData.value[field] }); 22 23 if (!result.success) { 24 errors.value[field] = result.error.issues[0]?.message; 25 } else { 26 delete errors.value[field]; 27 } 28}; 29 30const handleSubmit = async () => { 31 const result = loginSchema.safeParse(formData.value); 32 33 if (!result.success) { 34 errors.value = {}; 35 result.error.issues.forEach(issue => { 36 const field = issue.path[0] as keyof LoginForm; 37 errors.value[field] = issue.message; 38 }); 39 return; 40 } 41 42 console.log('Form submitted:', result.data); 43 // Send to API 44}; 45</script> 46 47<template> 48 <form @submit.prevent="handleSubmit" class="space-y-4"> 49 <div> 50 <input 51 v-model="formData.email" 52 type="email" 53 placeholder="Email" 54 @blur="validateField('email')" 55 :class="{ 'border-red-500': errors.email }" 56 /> 57 <span v-if="errors.email" class="text-red-500 text-sm">{{ errors.email }}</span> 58 </div> 59 60 <div> 61 <input 62 v-model="formData.password" 63 type="password" 64 placeholder="Password" 65 @blur="validateField('password')" 66 :class="{ 'border-red-500': errors.password }" 67 /> 68 <span v-if="errors.password" class="text-red-500 text-sm">{{ errors.password }}</span> 69 </div> 70 71 <button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded"> 72 Login 73 </button> 74 </form> 75</template>
Form Validation in Astro
astro1--- 2import { z } from 'zod'; 3 4const loginSchema = z.object({ 5 email: z.email({ error: 'Invalid email address' }), 6 password: z.string().min(8, { error: 'Password must be at least 8 characters' }), 7}); 8 9type LoginForm = z.infer<typeof loginSchema>; 10 11let errors: Partial<Record<keyof LoginForm, string>> = {}; 12let submittedData: LoginForm | null = null; 13 14if (Astro.request.method === 'POST') { 15 const formData = await Astro.request.formData(); 16 const data = { 17 email: formData.get('email'), 18 password: formData.get('password'), 19 }; 20 21 const result = loginSchema.safeParse(data); 22 23 if (!result.success) { 24 result.error.issues.forEach(issue => { 25 const field = issue.path[0] as keyof LoginForm; 26 errors[field] = issue.message; 27 }); 28 } else { 29 submittedData = result.data; 30 // Process form: send to API, create session, etc. 31 } 32} 33--- 34 35{submittedData && ( 36 <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded"> 37 Successfully logged in as {submittedData.email} 38 </div> 39)} 40 41<form method="POST" class="space-y-4"> 42 <div> 43 <input 44 name="email" 45 type="email" 46 placeholder="Email" 47 defaultValue="" 48 class={errors.email ? 'border-red-500' : ''} 49 /> 50 {errors.email && <span class="text-red-500 text-sm">{errors.email}</span>} 51 </div> 52 53 <div> 54 <input 55 name="password" 56 type="password" 57 placeholder="Password" 58 defaultValue="" 59 class={errors.password ? 'border-red-500' : ''} 60 /> 61 {errors.password && <span class="text-red-500 text-sm">{errors.password}</span>} 62 </div> 63 64 <button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded"> 65 Login 66 </button> 67</form>
Server-side Validation (Shared)
For both Vue and Astro, validate on server before processing:
typescript1// Shared validation utility 2import { z } from 'zod'; 3 4export const loginSchema = z.object({ 5 email: z.email(), 6 password: z.string().min(8), 7}); 8 9export type LoginData = z.infer<typeof loginSchema>; 10 11export async function validateLogin(data: unknown) { 12 return loginSchema.safeParse(data); 13} 14 15// Usage in API endpoint or action 16export async function POST({ request }) { 17 const data = await request.json(); 18 const result = await validateLogin(data); 19 20 if (!result.success) { 21 // Use flattenError for better form handling 22 // Returns: { formErrors: string[], fieldErrors: Record<string, string[]> } 23 return new Response( 24 JSON.stringify({ errors: z.flattenError(result.error) }), 25 { status: 400 } 26 ); 27 } 28 29 // Process validated data 30 return new Response(JSON.stringify({ success: true })); 31}
Client-side Validation with Fetch (Vue)
vue1<script setup lang="ts"> 2import { ref } from 'vue'; 3 4const email = ref(''); 5const password = ref(''); 6const errors = ref<Record<string, string>>({}); 7const loading = ref(false); 8 9const handleSubmit = async () => { 10 loading.value = true; 11 errors.value = {}; 12 13 try { 14 const response = await fetch('/api/login', { 15 method: 'POST', 16 headers: { 'Content-Type': 'application/json' }, 17 body: JSON.stringify({ email: email.value, password: password.value }), 18 }); 19 20 if (!response.ok) { 21 const { errors: serverErrors } = await response.json(); 22 if (serverErrors?.fieldErrors) { 23 Object.entries(serverErrors.fieldErrors).forEach(([field, messages]) => { 24 const firstMessage = Array.isArray(messages) ? messages[0] : messages; 25 if (firstMessage) { 26 errors.value[field] = firstMessage; 27 } 28 }); 29 } 30 return; 31 } 32 33 // Success: redirect or update state 34 window.location.href = '/dashboard'; 35 } catch (error) { 36 errors.value.submit = 'Network error. Please try again.'; 37 } finally { 38 loading.value = false; 39 } 40}; 41</script> 42 43<template> 44 <form @submit.prevent="handleSubmit" class="space-y-4"> 45 <div v-if="errors.submit" class="text-red-600 text-sm">{{ errors.submit }}</div> 46 47 <div> 48 <input v-model="email" type="email" placeholder="Email" /> 49 <span v-if="errors.email" class="text-red-500 text-sm">{{ errors.email }}</span> 50 </div> 51 52 <div> 53 <input v-model="password" type="password" placeholder="Password" /> 54 <span v-if="errors.password" class="text-red-500 text-sm">{{ errors.password }}</span> 55 </div> 56 57 <button :disabled="loading" type="submit" class="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"> 58 {{ loading ? 'Logging in...' : 'Login' }} 59 </button> 60 </form> 61</template>