Schemas
Velox TS uses Zod for type-safe validation schemas. Schemas provide runtime validation while TypeScript infers static types automatically.
Basic Schema Types
Section titled “Basic Schema Types”String Validation
Section titled “String Validation”Zod provides comprehensive string validation with built-in formats and transformations.
import { z } from '@veloxts/velox';
const StringSchemas = z.object({ // Basic string validation name: z.string(), // Any string required: z.string().min(1), // Non-empty string
// Length constraints username: z.string().min(3).max(20), // 3-20 characters exactCode: z.string().length(6), // Exactly 6 characters
// Format validation email: z.string().email(), // Email format url: z.string().url(), // Valid URL uuid: z.string().uuid(), // UUID format datetime: z.string().datetime(), // ISO 8601 datetime
// Pattern matching alphanumeric: z.string().regex(/^[a-zA-Z0-9]+$/),
// Transformations trimmed: z.string().trim(), // Remove whitespace lowercase: z.string().toLowerCase(), // Convert to lowercase uppercase: z.string().toUpperCase(), // Convert to UPPERCASE});Number Validation
Section titled “Number Validation”Number schemas support range constraints, type coercion, and mathematical validations.
const NumberSchemas = z.object({ // Basic number types age: z.number().int().positive(), // Positive integer price: z.number().nonnegative(), // 0 or greater (allows decimals) discount: z.number().min(0).max(100), // Range: 0-100
// Mathematical constraints even: z.number().multipleOf(2), // Even numbers only percentage: z.number().min(0).max(1), // 0.0 to 1.0
// Negative numbers temperature: z.number().negative(), // Less than 0
// Optional with default quantity: z.number().int().default(1), // Defaults to 1 if undefined});Boolean and Literal Values
Section titled “Boolean and Literal Values”const BooleanSchemas = z.object({ // Boolean isActive: z.boolean(),
// Literal values (exact match required) status: z.literal('published'), // Only accepts "published" apiVersion: z.literal('v2'),
// Optional boolean with default newsletter: z.boolean().default(false),});const DateSchemas = z.object({ // Date objects birthDate: z.date(),
// Date with constraints adultBirthday: z.date().max(new Date(Date.now() - 18 * 365 * 24 * 60 * 60 * 1000)),
// ISO string dates (common for APIs) createdAt: z.string().datetime(), updatedAt: z.string().datetime().optional(),});Arrays and Objects
Section titled “Arrays and Objects”Array Validation
Section titled “Array Validation”Validate arrays with constraints on elements and length.
const ArraySchemas = z.object({ // Basic arrays tags: z.array(z.string()), // String array scores: z.array(z.number().int()), // Integer array
// Length constraints roles: z.array(z.string()).min(1), // At least 1 element topThree: z.array(z.string()).max(3), // At most 3 elements exactFive: z.array(z.number()).length(5), // Exactly 5 elements
// Non-empty arrays categories: z.array(z.string()).nonempty(), // Requires at least 1
// Default empty array interests: z.array(z.string()).default([]),});
// Practical example: Product with multiple imagesconst ProductSchema = z.object({ id: z.string().uuid(), name: z.string().min(1), images: z.array(z.object({ url: z.string().url(), alt: z.string(), isPrimary: z.boolean(), })).min(1), // At least one image required});Nested Objects
Section titled “Nested Objects”Build complex validation by nesting schemas.
const AddressSchema = z.object({ street: z.string().min(1), city: z.string().min(1), state: z.string().length(2), zipCode: z.string().regex(/^\d{5}(-\d{4})?$/),});
const UserSchema = z.object({ id: z.string().uuid(), name: z.string().min(1), email: z.string().email(),
// Nested object address: AddressSchema,
// Optional nested object billingAddress: AddressSchema.optional(),
// Array of nested objects phoneNumbers: z.array(z.object({ type: z.enum(['home', 'work', 'mobile']), number: z.string().regex(/^\+?[1-9]\d{1,14}$/), })),});Optional vs Nullable
Section titled “Optional vs Nullable”Understanding the difference between optional and nullable is crucial.
// Optional - field can be undefined or omittedconst OptionalSchema = z.object({ description: z.string().optional(), // Valid: {} or { description: "text" } // Invalid: { description: null }});// Nullable - field can be null but must be presentconst NullableSchema = z.object({ description: z.string().nullable(), // Valid: { description: null } or { description: "text" } // Invalid: {} (field required)});// Optional + Nullable - can be undefined, null, or a stringconst FlexibleSchema = z.object({ description: z.string().nullable().optional(), // Valid: {}, { description: null }, { description: "text" }});
// Alternative syntax (same behavior)const AlternativeSchema = z.object({ description: z.string().nullish(), // Shorthand for nullable().optional()});Enums and Unions
Section titled “Enums and Unions”Restrict values to a predefined set.
// String enumconst UserRoleSchema = z.enum(['admin', 'editor', 'viewer']);type UserRole = z.infer<typeof UserRoleSchema>; // 'admin' | 'editor' | 'viewer'
// Native enum supportenum OrderStatus { Pending = 'PENDING', Processing = 'PROCESSING', Shipped = 'SHIPPED', Delivered = 'DELIVERED',}
const OrderSchema = z.object({ id: z.string().uuid(), status: z.nativeEnum(OrderStatus),});Unions
Section titled “Unions”Allow multiple types for a single field.
// Simple union - string or numberconst IdSchema = z.union([z.string(), z.number()]);
// Union with literalsconst StatusSchema = z.union([ z.literal('draft'), z.literal('published'), z.literal('archived'),]);
// Discriminated union - better type inferenceconst NotificationSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('email'), to: z.string().email(), subject: z.string(), body: z.string(), }), z.object({ type: z.literal('sms'), to: z.string(), message: z.string().max(160), }), z.object({ type: z.literal('push'), deviceId: z.string(), title: z.string(), body: z.string(), }),]);Custom Validation
Section titled “Custom Validation”Refine with Custom Rules
Section titled “Refine with Custom Rules”Add custom validation logic beyond built-in validators.
// Simple refine - single custom ruleconst PasswordSchema = z.string() .min(8, 'Password must be at least 8 characters') .refine( (password) => /[A-Z]/.test(password), { message: 'Password must contain at least one uppercase letter' } ) .refine( (password) => /[a-z]/.test(password), { message: 'Password must contain at least one lowercase letter' } ) .refine( (password) => /[0-9]/.test(password), { message: 'Password must contain at least one number' } );
// Object-level validationconst DateRangeSchema = z.object({ startDate: z.string().datetime(), endDate: z.string().datetime(),}).refine( (data) => new Date(data.endDate) > new Date(data.startDate), { message: 'End date must be after start date', path: ['endDate'] });
// Async validation (e.g., check database uniqueness)const UniqueEmailSchema = z.string().email().refine( async (email) => { const { db } = await import('@/api/database'); const existing = await db.user.findUnique({ where: { email } }); return !existing; }, { message: 'Email already in use' });SuperRefine for Complex Logic
Section titled “SuperRefine for Complex Logic”superRefine() provides fine-grained control over validation errors.
const RegistrationSchema = z.object({ email: z.string().email(), password: z.string().min(8), confirmPassword: z.string(), age: z.number().int(),}).superRefine((data, ctx) => { // Password confirmation check if (data.password !== data.confirmPassword) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passwords do not match', path: ['confirmPassword'], }); }
// Age requirement for email domain if (data.email.endsWith('@school.edu') && data.age < 13) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Students must be at least 13 years old', path: ['age'], }); }});Schema Composition
Section titled “Schema Composition”Extend Schemas
Section titled “Extend Schemas”Build on existing schemas by adding fields.
const BaseUserSchema = z.object({ name: z.string().min(1), email: z.string().email(),});
// Add new fieldsconst UserWithIdSchema = BaseUserSchema.extend({ id: z.string().uuid(), createdAt: z.string().datetime(),});
// Extend and overrideconst AdminUserSchema = BaseUserSchema.extend({ email: z.string().email().endsWith('@admin.example.com'), // Override validation permissions: z.array(z.string()), // Add new field});Merge Schemas
Section titled “Merge Schemas”Combine multiple schemas into one.
const ContactInfoSchema = z.object({ email: z.string().email(), phone: z.string().optional(),});
const AddressInfoSchema = z.object({ street: z.string(), city: z.string(),});
// Merge two schemasconst FullContactSchema = ContactInfoSchema.merge(AddressInfoSchema);// Result: { email, phone?, street, city }Pick and Omit
Section titled “Pick and Omit”Extract or exclude specific fields.
const UserSchema = z.object({ id: z.string().uuid(), name: z.string(), email: z.string().email(), password: z.string(), createdAt: z.string().datetime(),});
// Pick only specific fieldsconst UserPublicSchema = UserSchema.pick({ id: true, name: true, email: true });// Result: { id, name, email }
// Omit sensitive fieldsconst UserSafeSchema = UserSchema.omit({ password: true });// Result: { id, name, email, createdAt }Partial and Required
Section titled “Partial and Required”Make fields optional or required.
const CreateUserSchema = z.object({ name: z.string().min(1), email: z.string().email(), age: z.number().int().positive(),});
// Make all fields optional (useful for updates)const UpdateUserSchema = CreateUserSchema.partial();// All fields become optional: { name?, email?, age? }
// Make specific fields optionalconst PartialNameSchema = CreateUserSchema.partial({ name: true });// Result: { name?, email, age }
// Make all fields required (opposite of partial)const OptionalSchema = z.object({ name: z.string().optional(), email: z.string().optional(),});const RequiredSchema = OptionalSchema.required();// All fields become required: { name, email }Custom Error Messages
Section titled “Custom Error Messages”Customize validation error messages for better UX.
// Field-level custom messagesconst UserSchema = z.object({ name: z.string({ required_error: 'Name is required', invalid_type_error: 'Name must be a string', }).min(1, 'Name cannot be empty'),
email: z.string() .min(1, 'Email is required') .email('Invalid email format'),
age: z.number({ required_error: 'Age is required', invalid_type_error: 'Age must be a number', }) .int('Age must be a whole number') .positive('Age must be positive') .min(18, 'Must be at least 18 years old'),});
// Validation with custom errorsconst PriceSchema = z.number() .min(0, { message: 'Price cannot be negative' }) .max(999999, { message: 'Price cannot exceed $999,999' }) .refine( (price) => price % 0.01 === 0, { message: 'Price must have at most 2 decimal places' } );Resource API
Section titled “Resource API”For defining field-level visibility with resourceSchema() and projecting data based on access levels, see the dedicated Resource API guide.
Type Inference
Section titled “Type Inference”Zod provides powerful type inference utilities.
Basic Inference
Section titled “Basic Inference”const UserSchema = z.object({ id: z.string().uuid(), name: z.string(), email: z.string().email(), age: z.number().int().optional(),});
// Infer the TypeScript typetype User = z.infer<typeof UserSchema>;// Result: { id: string; name: string; email: string; age?: number | undefined }Input vs Output Types
Section titled “Input vs Output Types”Some schemas transform data (e.g., .default(), .transform()). Zod distinguishes input and output types.
const TransformSchema = z.object({ name: z.string().trim().toLowerCase(), // Transforms input tags: z.array(z.string()).default([]), // Adds default createdAt: z.string().datetime().transform((val) => new Date(val)), // String → Date});
type Input = z.input<typeof TransformSchema>;// { name: string; tags?: string[] | undefined; createdAt: string }
type Output = z.output<typeof TransformSchema>;// { name: string; tags: string[]; createdAt: Date }
// For most schemas without transforms, input and output are identicaltype Inferred = z.infer<typeof TransformSchema>; // Same as z.output<>Inferring from Procedures
Section titled “Inferring from Procedures”Velox TS procedures automatically infer input and output types:
const createUser = procedure() .input(z.object({ name: z.string(), email: z.string().email() })) .mutation(async ({ input, ctx }) => { // input: { name: string; email: string } — automatically typed return ctx.db.user.create({ data: input }); });
// Frontend client gets full type safetyimport { api } from '@/client';
const user = await api.users.createUser({ name: 'Alice', email: 'alice@example.com',});// user type is inferred from the handler's return valueWhen using the Resource API, output types are narrowed based on the projection level (public, authenticated, or admin).
Common Patterns
Section titled “Common Patterns”Input and Resource Schema Pairs
Section titled “Input and Resource Schema Pairs”Typical pattern for CRUD operations with Resource API.
import { resourceSchema } from '@veloxts/router';
// Input schemas for mutationsexport const CreateUserSchema = z.object({ name: z.string().min(1).max(100), email: z.string().email(), role: z.enum(['admin', 'editor', 'viewer']).default('viewer'),});
// Update schema - all fields optionalexport const UpdateUserSchema = CreateUserSchema.partial();
// Resource schema - defines field visibilityexport const UserSchema = resourceSchema() .public('id', z.string().uuid()) .public('name', z.string()) .public('role', z.enum(['admin', 'editor', 'viewer'])) .authenticated('email', z.string().email()) .admin('createdAt', z.date()) .admin('updatedAt', z.date()) .build();
// Filter/search schemaexport const UserFilterSchema = z.object({ role: z.enum(['admin', 'editor', 'viewer']).optional(), search: z.string().optional(), page: z.number().int().min(1).default(1), limit: z.number().int().min(1).max(100).default(20),});Reusable Schema Utilities
Section titled “Reusable Schema Utilities”Velox TS provides common schema helpers.
import { idParamSchema, // { id: string } paginationInputSchema, // { page, limit, sortBy?, sortOrder? }} from '@veloxts/validation';import { resource, resourceCollection } from '@veloxts/router';
// Use in proceduresexport const userProcedures = procedures('users', { // Get by ID getUser: procedure() .input(idParamSchema) .query(async ({ input, ctx }) => { const user = await ctx.db.user.findUniqueOrThrow({ where: { id: input.id } }); return resource(user, UserSchema.public); }),
// Paginated list listUsers: procedure() .input(paginationInputSchema) .query(async ({ input, ctx }) => { const [items, total] = await Promise.all([ ctx.db.user.findMany({ skip: (input.page - 1) * input.limit, take: input.limit, }), ctx.db.user.count(), ]);
return { data: resourceCollection(items, UserSchema.public), pagination: { page: input.page, limit: input.limit, total, }, }; }),});Sharing Schemas Across Stack
Section titled “Sharing Schemas Across Stack”Define schemas once, use everywhere.
import { z } from '@veloxts/velox';import { resourceSchema } from '@veloxts/router';
export const CreateProductSchema = z.object({ name: z.string().min(1, 'Product name is required').max(200), price: z.number().positive('Price must be positive'), description: z.string().max(2000),});
export const ProductSchema = resourceSchema() .public('id', z.string().uuid()) .public('name', z.string()) .public('price', z.number()) .authenticated('description', z.string()) .admin('createdAt', z.date()) .build();
// Backend procedureimport { CreateProductSchema, ProductSchema } from '@shared/schemas/product';import { resource } from '@veloxts/router';
export const productProcedures = procedures('products', { createProduct: procedure() .input(CreateProductSchema) .mutation(async ({ input, ctx }) => { const product = await ctx.db.product.create({ data: input }); return resource(product, ProductSchema.authenticated); }),});
// Frontend form validationimport { CreateProductSchema } from '@shared/schemas/product';
function ProductForm() { const handleSubmit = (data: unknown) => { const result = CreateProductSchema.safeParse(data); if (!result.success) { // Show validation errors console.error(result.error.flatten()); return; }
// Submit validated data api.products.createProduct(result.data); };}Related Content
Section titled “Related Content”- Resource API - Field-level visibility and projection
- Coercion - Automatic type conversion from strings
- Pagination - Pagination helpers and patterns
- Procedures - Using schemas in procedure definitions
Learn More
Section titled “Learn More”- Zod Official Documentation - Complete Zod reference
- Zod Error Handling - Advanced error customization
- Zod Coercion - Type coercion patterns