Zod Schema Type Inference Chain
The Type Inference Chain Pattern (CRITICAL)
The Type Inference Chain is the foundational pattern for all state and form management in this plugin:
code
Zod Schema (single source of truth)
↓
z.infer<typeof schema> (generate TypeScript type)
↓
useForm<Type>() (type-safe form)
↓
Zustand Store<Type> (type-safe state)
This pattern ensures absolute type safety across your entire data layer by maintaining one single source of truth: the Zod schema.
Why This Pattern is Critical
Problem Without Type Inference Chain:
typescript
// ❌ BAD: Multiple sources of truth
interface UserData { // Manual type definition
email: string;
age: number;
}
const userSchema = z.object({ // Zod schema
email: z.string().email(),
age: z.number().min(18),
});
// These can drift out of sync!
// What if you add a field to the schema but forget the interface?
// What if you change validation rules?
Solution With Type Inference Chain:
typescript
// ✅ GOOD: Single source of truth
const userSchema = z.object({
email: z.string().email('Invalid email'),
age: z.number().min(18, 'Must be 18+'),
});
// Type is automatically inferred from schema
type UserData = z.infer<typeof userSchema>;
// Now the schema and type are ALWAYS in sync!
// Change schema → type automatically updates!
Schema Definition Patterns
Basic Object Schema
typescript
import { z } from 'zod';
// Define schema with validation rules
const userSchema = z.object({
// String with validations
username: z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be at most 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores'),
// Email validation
email: z.string()
.email('Invalid email address'),
// Number with range
age: z.number()
.int('Must be a whole number')
.min(18, 'Must be 18 or older')
.max(120, 'Invalid age'),
// Optional field
middleName: z.string().optional(),
// Nullable field
nickname: z.string().nullable(),
// Enum
role: z.enum(['user', 'admin', 'moderator']),
// Boolean
isActive: z.boolean(),
// Date
birthDate: z.date()
.max(new Date(), 'Birth date cannot be in future'),
// URL
website: z.string().url('Invalid URL').optional(),
// Array of strings
tags: z.array(z.string())
.min(1, 'At least one tag required')
.max(5, 'Maximum 5 tags'),
});
// Infer TypeScript type
type User = z.infer<typeof userSchema>;
// Now User type is:
// {
// username: string;
// email: string;
// age: number;
// middleName?: string;
// nickname: string | null;
// role: 'user' | 'admin' | 'moderator';
// isActive: boolean;
// birthDate: Date;
// website?: string;
// tags: string[];
// }
Nested Object Schema
typescript
const addressSchema = z.object({
street: z.string().min(1, 'Street required'),
city: z.string().min(1, 'City required'),
state: z.string().length(2, 'Use 2-letter state code'),
zipCode: z.string().regex(/^\d{5}$/, 'Must be 5 digits'),
});
const userWithAddressSchema = z.object({
name: z.string(),
email: z.string().email(),
address: addressSchema, // Nested object
});
type UserWithAddress = z.infer<typeof userWithAddressSchema>;
// {
// name: string;
// email: string;
// address: {
// street: string;
// city: string;
// state: string;
// zipCode: string;
// };
// }
Array of Objects
typescript
const itemSchema = z.object({
name: z.string(),
quantity: z.number().int().min(1),
price: z.number().positive(),
});
const orderSchema = z.object({
orderId: z.string().uuid(),
items: z.array(itemSchema)
.min(1, 'Order must have at least one item'),
totalPrice: z.number().positive(),
});
type Order = z.infer<typeof orderSchema>;
// {
// orderId: string;
// items: Array<{
// name: string;
// quantity: number;
// price: number;
// }>;
// totalPrice: number;
// }
Schema Composition and Reusability
Using .extend()
typescript
const baseUserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
});
// Add more fields to base schema
const fullUserSchema = baseUserSchema.extend({
name: z.string(),
age: z.number(),
role: z.enum(['user', 'admin']),
});
type FullUser = z.infer<typeof fullUserSchema>;
// {
// id: string;
// email: string;
// name: string;
// age: number;
// role: 'user' | 'admin';
// }
Using .merge()
typescript
const nameSchema = z.object({
firstName: z.string(),
lastName: z.string(),
});
const contactSchema = z.object({
email: z.string().email(),
phone: z.string(),
});
// Merge two schemas
const personSchema = nameSchema.merge(contactSchema);
type Person = z.infer<typeof personSchema>;
// {
// firstName: string;
// lastName: string;
// email: string;
// phone: string;
// }
Using .pick() and .omit()
typescript
const userSchema = z.object({
id: z.string(),
email: z.string().email(),
password: z.string(),
name: z.string(),
});
// Pick only specific fields
const loginSchema = userSchema.pick({
email: true,
password: true,
});
// { email: string; password: string; }
// Omit specific fields
const publicUserSchema = userSchema.omit({
password: true,
});
// { id: string; email: string; name: string; }
Cross-Field Validation with .refine()
Password Confirmation
typescript
const passwordSchema = z.object({
password: z.string()
.min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'], // Error appears on confirmPassword field
});
type PasswordForm = z.infer<typeof passwordSchema>;
Conditional Validation
typescript
const shippingSchema = z.object({
shippingMethod: z.enum(['pickup', 'delivery']),
address: z.string().optional(),
}).refine(
(data) => {
// If delivery is selected, address is required
if (data.shippingMethod === 'delivery') {
return data.address && data.address.length > 0;
}
return true;
},
{
message: 'Address required for delivery',
path: ['address'],
}
);
Date Range Validation
typescript
const eventSchema = z.object({
startDate: z.date(),
endDate: z.date(),
}).refine((data) => data.endDate > data.startDate, {
message: 'End date must be after start date',
path: ['endDate'],
});
Safe Error Handling with .safeParse()
Basic Safe Parsing
typescript
const userSchema = z.object({
email: z.string().email(),
age: z.number().min(18),
});
// Unsafe data from user input or API
const userData = {
email: 'invalid-email',
age: 15,
};
// Use safeParse for validation
const result = userSchema.safeParse(userData);
if (!result.success) {
// Validation failed - handle errors
console.error('Validation errors:', result.error.issues);
// Get formatted errors
const formatted = result.error.format();
console.log(formatted.email?._errors); // ["Invalid email address"]
console.log(formatted.age?._errors); // ["Number must be greater than or equal to 18"]
// Get flat errors
const flat = result.error.flatten();
console.log(flat.fieldErrors);
// {
// email: ["Invalid email address"],
// age: ["Number must be greater than or equal to 18"]
// }
} else {
// Validation succeeded - use validated data
const validUser = result.data; // Fully typed!
console.log(validUser.email); // TypeScript knows this is valid
}
API Response Validation
typescript
const apiResponseSchema = z.object({
success: z.boolean(),
data: z.object({
id: z.string(),
name: z.string(),
}),
});
async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`);
const json = await response.json();
// Validate API response
const result = apiResponseSchema.safeParse(json);
if (!result.success) {
throw new Error(`Invalid API response: ${result.error.message}`);
}
// Safe to use - data is validated
return result.data;
}
Complete Type Inference Chain Example
Step 1: Define Zod Schema (Single Source of Truth)
typescript
// lib/schemas/user-profile-schema.ts
import { z } from 'zod';
export const userProfileSchema = z.object({
name: z.string()
.min(1, 'Name is required')
.max(100, 'Name too long'),
email: z.string()
.email('Invalid email address'),
bio: z.string()
.max(500, 'Bio must be at most 500 characters')
.optional(),
age: z.number()
.int('Must be a whole number')
.min(18, 'Must be 18 or older')
.optional(),
});
// Step 2: Infer TypeScript type
export type UserProfileData = z.infer<typeof userProfileSchema>;
Step 3: Use in React Hook Form
typescript
// components/forms/UserProfileForm.tsx
'use client'
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { userProfileSchema, type UserProfileData } from '@/lib/schemas/user-profile-schema';
export function UserProfileForm() {
const { register, handleSubmit } = useForm<UserProfileData>({
resolver: zodResolver(userProfileSchema), // Schema drives validation
});
const onSubmit = (data: UserProfileData) => {
// data is fully typed and validated!
};
return <form onSubmit={handleSubmit(onSubmit)}>{/* fields */}</form>;
}
Step 4: Use in Zustand Store
typescript
// lib/stores/user-store.ts
import { create } from 'zustand';
import { type UserProfileData } from '@/lib/schemas/user-profile-schema';
interface UserStore {
profile: UserProfileData | null;
updateProfile: (data: UserProfileData) => void;
}
export const useUserStore = create<UserStore>()((set) => ({
profile: null,
updateProfile: (data) => set({ profile: data }),
}));
Result: Complete Type Safety
code
userProfileSchema (Zod)
↓ z.infer
UserProfileData (TypeScript type)
↓
useForm<UserProfileData> (React Hook Form)
↓
useUserStore.updateProfile(data: UserProfileData) (Zustand)
All components share the same type, derived from the same schema!
Anti-Patterns (DO NOT DO)
❌ Manually Defining Types
typescript
// WRONG: Manual type separate from schema
interface UserData {
email: string;
age: number;
}
const userSchema = z.object({
email: z.string().email(),
age: z.number(),
});
// Problem: These can drift out of sync!
Correct:
typescript
const userSchema = z.object({
email: z.string().email(),
age: z.number(),
});
type UserData = z.infer<typeof userSchema>; // Always in sync!
❌ Using .parse() Without Try/Catch
typescript
// WRONG: Throws unhandled exception on invalid data const user = userSchema.parse(untrustedData);
Correct:
typescript
const result = userSchema.safeParse(untrustedData);
if (!result.success) {
// Handle error
} else {
const user = result.data;
}
❌ Ignoring Validation Errors
typescript
// WRONG: Not checking success flag const result = userSchema.safeParse(data); const user = result.data; // Could be undefined!
Correct:
typescript
const result = userSchema.safeParse(data);
if (result.success) {
const user = result.data; // Type-safe!
}
Summary
The zod-schema-type-inference-chain skill establishes the foundational pattern for all data management:
- •Define Zod schema - Single source of truth for data structure and validation
- •Infer TypeScript type - Use
z.infer<typeof schema>to generate type - •Use in forms - Pass type to
useForm<Type>({ resolver: zodResolver(schema) }) - •Use in stores - Pass type to Zustand store interface
- •Validate safely - Use
.safeParse()for all external data
This pattern ensures absolute type safety across your entire application with zero duplication.