Validation Patterns
The template uses Zod for schema-based validation across all API boundaries. Validation schemas define data shapes, constraints, transformations, and type inference in a single source of truth. Each domain has its own validation module with schemas for create, update, and query operations.
Architecture Overview
Source Files
| File | Purpose |
|---|---|
lib/validations/auth.ts | Password and authentication schemas |
lib/validations/item.ts | Item location data schema |
lib/validations/client-item.ts | Client-facing item create/update/query schemas |
lib/validations/company.ts | Company CRUD and item-company association schemas |
lib/validations/sponsor-ad.ts | Sponsor ad lifecycle schemas |
lib/validations/client-dashboard.ts | Dashboard query parameter schemas |
lib/validations/user-location.ts | User location and privacy settings |
Core Patterns
Pattern 1: Schema + Inferred Type
Every schema exports a corresponding TypeScript type via z.infer:
import { z } from 'zod';
export const createCompanySchema = z.object({
name: z.string().min(1, "Company name is required").max(255),
website: z.string().url("Invalid URL format").optional().or(z.literal("")),
status: z.enum(["active", "inactive"]).default("active"),
});
export type CreateCompanyInput = z.infer<typeof createCompanySchema>;
// Inferred type:
// {
// name: string;
// website?: string | "";
// status: "active" | "inactive";
// }
Pattern 2: Transform and Normalize
Schemas use .transform() to normalize input data:
domain: z.string()
.max(255)
.optional()
.transform((val) => val?.toLowerCase().trim() || undefined),
slug: z.string()
.max(255)
.optional()
.transform((val) => val?.toLowerCase().trim() || undefined)
.refine(
(val) => !val || /^[a-z0-9-]+$/.test(val),
{ message: "Slug must contain only lowercase letters, numbers, and hyphens" }
),
Pattern 3: Enum Constraints
Status fields use z.enum() with const arrays for type safety:
export const companyStatus = ["active", "inactive"] as const;
export const sponsorAdStatuses = [
"pending_payment", "pending", "rejected",
"active", "expired", "cancelled",
] as const;
export const sponsorAdIntervals = ["weekly", "monthly"] as const;
// Usage in schemas
status: z.enum(companyStatus).default("active"),
interval: z.enum(sponsorAdIntervals),
Pattern 4: Coerced Query Parameters
Query string parameters from HTTP requests are coerced from strings:
export const querySponsorAdsSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(10),
status: z.enum(sponsorAdStatuses).optional(),
sortBy: z.enum(["createdAt", "updatedAt", "startDate", "endDate", "status"]).default("createdAt"),
sortOrder: z.enum(["asc", "desc"]).default("desc"),
});
Pattern 5: String-to-Number Transform
For query parameters that arrive as strings but represent numbers:
page: z.string()
.optional()
.transform(val => (val ? parseInt(val, 10) : 1))
.refine(val => !Number.isNaN(val), { message: 'Page must be a valid number' })
.refine(val => val >= 1, { message: 'Page must be at least 1' }),
deleted: z.string()
.optional()
.transform(val => val === 'true'), // String "true" -> boolean true
Pattern 6: Cross-Field Validation with Refine
Complex validation rules that span multiple fields:
export const updateLocationSchema = z.object({
defaultLatitude: z.number().min(-90).max(90).nullable().optional(),
defaultLongitude: z.number().min(-180).max(180).nullable().optional(),
defaultCity: z.string().max(200).nullable().optional(),
defaultCountry: z.string().max(100).nullable().optional(),
locationPrivacy: locationPrivacySchema.optional(),
}).refine(
(data) => {
const hasLat = data.defaultLatitude != null;
const hasLng = data.defaultLongitude != null;
return hasLat === hasLng; // Both or neither
},
{ message: 'Both latitude and longitude must be provided together' }
);
Pattern 7: Union Types
Fields that accept multiple formats:
category: z.union([
z.string().min(1, 'Category is required'),
z.array(z.string().min(1)).min(1, 'At least one category is required'),
]).optional().nullable(),
Domain Schemas
Authentication
Password validation with multiple regex constraints:
export const passwordSchema = z.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Must contain at least one uppercase letter")
.regex(/[a-z]/, "Must contain at least one lowercase letter")
.regex(/[0-9]/, "Must contain at least one number")
.regex(/[^A-Za-z0-9]/, "Must contain at least one special character");
Item Location
Geographic data with bounded coordinates:
export const locationSchema = z.object({
address: z.string().optional(),
city: z.string().optional(),
state: z.string().optional(),
country: z.string().optional(),
postal_code: z.string().optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
service_area: z.enum(['local', 'regional', 'national', 'global']).optional(),
is_remote: z.boolean().optional(),
geocoded_by: z.enum(['mapbox', 'google']).optional(),
}).optional();
User Location Privacy
Enum-based privacy settings:
export const locationPrivacyValues = ['private', 'city', 'exact'] as const;
export const locationPrivacySchema = z.enum(locationPrivacyValues);
export type LocationPrivacy = z.infer<typeof locationPrivacySchema>;
Client Item Submission
Full create schema with external validation constants:
import { ITEM_VALIDATION } from '@/lib/types/item';
export const clientCreateItemSchema = z.object({
name: z.string()
.min(ITEM_VALIDATION.NAME_MIN_LENGTH)
.max(ITEM_VALIDATION.NAME_MAX_LENGTH),
description: z.string()
.min(ITEM_VALIDATION.DESCRIPTION_MIN_LENGTH)
.max(ITEM_VALIDATION.DESCRIPTION_MAX_LENGTH),
source_url: z.string().url('Invalid URL format'),
category: z.union([
z.string().min(1),
z.array(z.string().min(1)).min(1),
]).optional().nullable(),
tags: z.array(z.string().min(1)).optional().default([]),
icon_url: z.string().url().optional().or(z.literal('')),
location: locationSchema,
});
Sponsor Ad Lifecycle
Multiple schemas covering the full sponsor ad workflow:
| Schema | Purpose |
|---|---|
createSponsorAdSchema | New sponsor ad submission |
updateSponsorAdSchema | Admin update (status, dates, subscription) |
approveSponsorAdSchema | Admin approval |
rejectSponsorAdSchema | Admin rejection with reason (10-500 chars) |
cancelSponsorAdSchema | Cancellation with optional reason |
querySponsorAdsSchema | Paginated listing with filters |
Schema Reuse Patterns
Partial Schemas for Updates
Update schemas often mirror create schemas with all fields optional:
export const updateCompanySchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(255).optional(),
website: z.string().url().optional().or(z.literal("")),
status: z.enum(companyStatus).optional(),
});
Schema Aliasing
When two operations have identical validation needs:
export const assignCompanyToItemSchema = z.object({
itemSlug: z.string().min(1).max(255).transform(val => val.toLowerCase().trim()),
companyId: z.string().uuid("Invalid company ID format"),
});
// Reuse for updates (identical validation)
export const updateItemCompanySchema = assignCompanyToItemSchema;
Selective Picking
Using .pick() to create subset schemas:
const validatedData = userValidationSchema
.pick({ email: true, password: true })
.parse(data);
Usage in API Routes
import { clientCreateItemSchema } from '@/lib/validations/client-item';
export async function POST(request: Request) {
const body = await request.json();
// Validation + transformation in one step
const result = clientCreateItemSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ errors: result.error.flatten().fieldErrors },
{ status: 400 }
);
}
// result.data is fully typed and transformed
const item = await repository.create(result.data);
return Response.json(item, { status: 201 });
}