Skip to content

Schemas

Velox TS uses Zod for type-safe validation schemas. Schemas provide runtime validation while TypeScript infers static types automatically.

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 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
});
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(),
});

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 images
const 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
});

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}$/),
})),
});

Understanding the difference between optional and nullable is crucial.

// Optional - field can be undefined or omitted
const OptionalSchema = z.object({
description: z.string().optional(),
// Valid: {} or { description: "text" }
// Invalid: { description: null }
});

Restrict values to a predefined set.

// String enum
const UserRoleSchema = z.enum(['admin', 'editor', 'viewer']);
type UserRole = z.infer<typeof UserRoleSchema>; // 'admin' | 'editor' | 'viewer'
// Native enum support
enum OrderStatus {
Pending = 'PENDING',
Processing = 'PROCESSING',
Shipped = 'SHIPPED',
Delivered = 'DELIVERED',
}
const OrderSchema = z.object({
id: z.string().uuid(),
status: z.nativeEnum(OrderStatus),
});

Allow multiple types for a single field.

// Simple union - string or number
const IdSchema = z.union([z.string(), z.number()]);
// Union with literals
const StatusSchema = z.union([
z.literal('draft'),
z.literal('published'),
z.literal('archived'),
]);
// Discriminated union - better type inference
const 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(),
}),
]);

Add custom validation logic beyond built-in validators.

// Simple refine - single custom rule
const 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 validation
const 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() 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'],
});
}
});

Build on existing schemas by adding fields.

const BaseUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
// Add new fields
const UserWithIdSchema = BaseUserSchema.extend({
id: z.string().uuid(),
createdAt: z.string().datetime(),
});
// Extend and override
const AdminUserSchema = BaseUserSchema.extend({
email: z.string().email().endsWith('@admin.example.com'), // Override validation
permissions: z.array(z.string()), // Add new field
});

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 schemas
const FullContactSchema = ContactInfoSchema.merge(AddressInfoSchema);
// Result: { email, phone?, street, city }

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 fields
const UserPublicSchema = UserSchema.pick({ id: true, name: true, email: true });
// Result: { id, name, email }
// Omit sensitive fields
const UserSafeSchema = UserSchema.omit({ password: true });
// Result: { id, name, email, createdAt }

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 optional
const 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 }

Customize validation error messages for better UX.

// Field-level custom messages
const 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 errors
const 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' }
);

For defining field-level visibility with resourceSchema() and projecting data based on access levels, see the dedicated Resource API guide.

Zod provides powerful type inference utilities.

const UserSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
age: z.number().int().optional(),
});
// Infer the TypeScript type
type User = z.infer<typeof UserSchema>;
// Result: { id: string; name: string; email: string; age?: number | undefined }

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 identical
type Inferred = z.infer<typeof TransformSchema>; // Same as z.output<>

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 safety
import { api } from '@/client';
const user = await api.users.createUser({
name: 'Alice',
email: 'alice@example.com',
});
// user type is inferred from the handler's return value

When using the Resource API, output types are narrowed based on the projection level (public, authenticated, or admin).

Typical pattern for CRUD operations with Resource API.

import { resourceSchema } from '@veloxts/router';
// Input schemas for mutations
export 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 optional
export const UpdateUserSchema = CreateUserSchema.partial();
// Resource schema - defines field visibility
export 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 schema
export 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),
});

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 procedures
export 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,
},
};
}),
});

Define schemas once, use everywhere.

shared/schemas/product.ts
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 procedure
import { 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 validation
import { 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);
};
}
  • Resource API - Field-level visibility and projection
  • Coercion - Automatic type conversion from strings
  • Pagination - Pagination helpers and patterns
  • Procedures - Using schemas in procedure definitions