Skip to content

Custom Schemas

Learn how to create and extend validation schemas for your specific use cases.

Creating Basic Schemas

Simple Object Schema

typescript
import { z } from 'zod';

// Define a product schema
const ProductSchema = z.object({
  id: z.string().uuid('Product ID must be a valid UUID'),
  name: z.string().min(1, 'Product name is required').max(100),
  price: z.number().positive('Price must be positive'),
  category: z.enum(['electronics', 'clothing', 'books', 'home']),
  inStock: z.boolean(),
  tags: z.array(z.string()).optional(),
  metadata: z.record(z.string()).optional()
});

// Export type for TypeScript
export type Product = z.infer<typeof ProductSchema>;

// Create validation function
export function validateProduct(data: unknown) {
  try {
    const product = ProductSchema.parse(data);
    return { success: true, data: product };
  } catch (error) {
    if (error instanceof z.ZodError) {
      const validationError = new ValidationError('Product validation failed', error.issues);
      return { success: false, error: validationError.getFormattedMessage() };
    }
    return { success: false, error: 'Unknown validation error' };
  }
}

Nested Object Schema

typescript
// Address schema
const AddressSchema = z.object({
  street: z.string().min(1, 'Street is required'),
  city: z.string().min(1, 'City is required'),
  state: z.string().length(2, 'State must be 2 characters'),
  zipCode: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code format'),
  country: z.string().default('US')
});

// Customer schema with nested address
const CustomerSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  phone: z.string().regex(/^\+?[\d\s\-\(\)]+$/, 'Invalid phone format').optional(),
  address: AddressSchema,
  billingAddress: AddressSchema.optional(),
  preferences: z.object({
    newsletter: z.boolean().default(false),
    smsNotifications: z.boolean().default(false),
    preferredLanguage: z.string().length(2).default('en')
  }).optional()
});

export type Customer = z.infer<typeof CustomerSchema>;

Schema Composition

Extending Existing Schemas

typescript
// Extend UserInputSchema for different user types
import { UserInputSchema } from './validation.js';

// Admin user with additional fields
const AdminUserSchema = UserInputSchema.extend({
  role: z.enum(['admin', 'super_admin']),
  permissions: z.array(z.string()),
  lastLogin: z.date().optional(),
  isActive: z.boolean().default(true)
});

// Guest user with minimal fields
const GuestUserSchema = UserInputSchema.pick({
  name: true,
  email: true
}).extend({
  sessionId: z.string().uuid(),
  ipAddress: z.string().ip()
});

export type AdminUser = z.infer<typeof AdminUserSchema>;
export type GuestUser = z.infer<typeof GuestUserSchema>;

Schema Merging

typescript
// Base entity schema
const BaseEntitySchema = z.object({
  id: z.string().uuid(),
  createdAt: z.date(),
  updatedAt: z.date(),
  version: z.number().int().positive()
});

// Merge with specific schemas
const ProductWithMetaSchema = ProductSchema.merge(BaseEntitySchema);
const CustomerWithMetaSchema = CustomerSchema.merge(BaseEntitySchema);

// Or use intersection
const ProductEntity = ProductSchema.and(BaseEntitySchema);

Conditional Schemas

typescript
// Schema that changes based on user type
const UserSchema = z.discriminatedUnion('type', [
  z.object({
    type: z.literal('customer'),
    name: z.string(),
    email: z.string().email(),
    loyaltyPoints: z.number().int().min(0)
  }),
  z.object({
    type: z.literal('admin'),
    name: z.string(),
    email: z.string().email(),
    permissions: z.array(z.string()),
    department: z.string()
  }),
  z.object({
    type: z.literal('guest'),
    sessionId: z.string().uuid(),
    ipAddress: z.string().ip()
  })
]);

export type User = z.infer<typeof UserSchema>;

Advanced Validation Patterns

Custom Validation Functions

typescript
// Custom password validation
const PasswordSchema = z.string()
  .min(8, 'Password must be at least 8 characters')
  .regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
  .regex(/[a-z]/, 'Password must contain at least one lowercase letter')
  .regex(/\d/, 'Password must contain at least one number')
  .regex(/[!@#$%^&*]/, 'Password must contain at least one special character');

// Custom date validation
const FutureDateSchema = z.date().refine(
  (date) => date > new Date(),
  { message: 'Date must be in the future' }
);

// Custom business logic validation
const OrderSchema = z.object({
  items: z.array(z.object({
    productId: z.string().uuid(),
    quantity: z.number().int().positive(),
    price: z.number().positive()
  })).min(1, 'Order must have at least one item'),
  shippingAddress: AddressSchema,
  total: z.number().positive()
}).refine(
  (order) => {
    const calculatedTotal = order.items.reduce(
      (sum, item) => sum + (item.quantity * item.price), 
      0
    );
    return Math.abs(calculatedTotal - order.total) < 0.01;
  },
  { message: 'Order total does not match item prices' }
);

Async Validation

typescript
// Async validation for unique email
const UniqueEmailSchema = z.string().email().refine(
  async (email) => {
    const existingUser = await db.user.findUnique({ where: { email } });
    return !existingUser;
  },
  { message: 'Email already exists' }
);

// Usage with async validation
async function validateUserWithUniqueEmail(userData: unknown) {
  try {
    const UserWithUniqueEmailSchema = UserInputSchema.extend({
      email: UniqueEmailSchema
    });
    
    const user = await UserWithUniqueEmailSchema.parseAsync(userData);
    return { success: true, data: user };
  } catch (error) {
    if (error instanceof z.ZodError) {
      return { success: false, error: error.message };
    }
    return { success: false, error: 'Unknown validation error' };
  }
}

Schema Transformations

Data Preprocessing

typescript
// Transform and validate data
const ProcessedUserSchema = z.object({
  name: z.string().transform(name => name.trim().toLowerCase()),
  email: z.string().email().transform(email => email.toLowerCase()),
  age: z.string().transform(str => parseInt(str, 10)).pipe(z.number().int().positive()),
  tags: z.string().transform(str => str.split(',').map(tag => tag.trim())).pipe(z.array(z.string()))
});

// Usage
const result = ProcessedUserSchema.safeParse({
  name: '  John Doe  ',
  email: 'JOHN@EXAMPLE.COM',
  age: '30',
  tags: 'developer, typescript, node.js'
});

if (result.success) {
  console.log(result.data);
  // {
  //   name: 'john doe',
  //   email: 'john@example.com',
  //   age: 30,
  //   tags: ['developer', 'typescript', 'node.js']
  // }
}

Default Values and Preprocessing

typescript
const ConfigWithDefaultsSchema = z.object({
  database: z.object({
    host: z.string().default('localhost'),
    port: z.number().int().default(5432),
    name: z.string(),
    ssl: z.boolean().default(false)
  }),
  cache: z.object({
    enabled: z.boolean().default(true),
    ttl: z.number().int().default(3600),
    maxSize: z.number().int().default(1000)
  }).optional(),
  features: z.record(z.boolean()).default({})
}).transform((config) => {
  // Post-process the configuration
  if (!config.cache) {
    config.cache = {
      enabled: false,
      ttl: 0,
      maxSize: 0
    };
  }
  return config;
});

Schema Utilities

Schema Factory Functions

typescript
// Factory for creating paginated response schemas
function createPaginatedSchema<T extends z.ZodTypeAny>(itemSchema: T) {
  return z.object({
    items: z.array(itemSchema),
    pagination: z.object({
      page: z.number().int().positive(),
      limit: z.number().int().positive(),
      total: z.number().int().min(0),
      totalPages: z.number().int().min(0)
    })
  });
}

// Usage
const PaginatedProductsSchema = createPaginatedSchema(ProductSchema);
const PaginatedUsersSchema = createPaginatedSchema(UserInputSchema);

export type PaginatedProducts = z.infer<typeof PaginatedProductsSchema>;

Schema Validation Helpers

typescript
// Generic validation helper
export function createValidator<T>(schema: z.ZodSchema<T>) {
  return (data: unknown): { success: true; data: T } | { success: false; error: string } => {
    try {
      const validatedData = schema.parse(data);
      return { success: true, data: validatedData };
    } catch (error) {
      if (error instanceof z.ZodError) {
        const validationError = new ValidationError('Validation failed', error.issues);
        return { success: false, error: validationError.getFormattedMessage() };
      }
      return { success: false, error: 'Unknown validation error' };
    }
  };
}

// Create validators
export const validateProduct = createValidator(ProductSchema);
export const validateCustomer = createValidator(CustomerSchema);
export const validateOrder = createValidator(OrderSchema);

Schema Testing Utilities

typescript
// Generate test data from schema
import { faker } from '@faker-js/faker';

function generateTestData<T>(schema: z.ZodSchema<T>): T {
  // This is a simplified example - you'd need a more sophisticated generator
  if (schema instanceof z.ZodObject) {
    const shape = schema.shape;
    const testData: any = {};
    
    for (const [key, fieldSchema] of Object.entries(shape)) {
      if (fieldSchema instanceof z.ZodString) {
        testData[key] = faker.lorem.word();
      } else if (fieldSchema instanceof z.ZodNumber) {
        testData[key] = faker.number.int({ min: 1, max: 100 });
      } else if (fieldSchema instanceof z.ZodBoolean) {
        testData[key] = faker.datatype.boolean();
      }
      // Add more type handlers as needed
    }
    
    return testData;
  }
  
  throw new Error('Unsupported schema type for test data generation');
}

// Usage in tests
const testProduct = generateTestData(ProductSchema);
const testUser = generateTestData(UserInputSchema);

Integration Patterns

Database Schema Sync

typescript
// Keep Zod schemas in sync with database schemas
import { z } from 'zod';

// Database table definition (Prisma example)
// model User {
//   id        String   @id @default(uuid())
//   name      String
//   email     String   @unique
//   createdAt DateTime @default(now())
//   updatedAt DateTime @updatedAt
// }

// Corresponding Zod schema
const DatabaseUserSchema = z.object({
  id: z.string().uuid(),
  name: z.string(),
  email: z.string().email(),
  createdAt: z.date(),
  updatedAt: z.date()
});

// Input schema (subset for creation)
const CreateUserSchema = DatabaseUserSchema.omit({
  id: true,
  createdAt: true,
  updatedAt: true
});

// Update schema (all fields optional except id)
const UpdateUserSchema = DatabaseUserSchema.partial().required({ id: true });

API Schema Versioning

typescript
// Version 1 schema
const UserV1Schema = z.object({
  name: z.string(),
  email: z.string().email()
});

// Version 2 schema (with additional fields)
const UserV2Schema = UserV1Schema.extend({
  phone: z.string().optional(),
  preferences: z.object({
    newsletter: z.boolean().default(false)
  }).optional()
});

// Version-aware validation
function validateUserByVersion(data: unknown, version: string) {
  switch (version) {
    case 'v1':
      return createValidator(UserV1Schema)(data);
    case 'v2':
      return createValidator(UserV2Schema)(data);
    default:
      return { success: false, error: 'Unsupported API version' };
  }
}

Best Practices

✅ Do

  • Keep schemas focused - One schema per logical entity
  • Use descriptive error messages - Help users understand validation failures
  • Leverage TypeScript inference - Let Zod generate your types
  • Compose schemas - Build complex schemas from simple ones
  • Test your schemas - Write tests for validation logic

❌ Don't

  • Create overly complex schemas - Break them down into smaller pieces
  • Ignore performance - Cache compiled schemas for repeated use
  • Skip error handling - Always handle validation failures
  • Hardcode validation logic - Use schemas for consistency
  • Forget about backwards compatibility - Version your schemas

Next Steps

Released under the MIT License.