Skip to main content

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

FilePurpose
lib/validations/auth.tsPassword and authentication schemas
lib/validations/item.tsItem location data schema
lib/validations/client-item.tsClient-facing item create/update/query schemas
lib/validations/company.tsCompany CRUD and item-company association schemas
lib/validations/sponsor-ad.tsSponsor ad lifecycle schemas
lib/validations/client-dashboard.tsDashboard query parameter schemas
lib/validations/user-location.tsUser 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,
});

Multiple schemas covering the full sponsor ad workflow:

SchemaPurpose
createSponsorAdSchemaNew sponsor ad submission
updateSponsorAdSchemaAdmin update (status, dates, subscription)
approveSponsorAdSchemaAdmin approval
rejectSponsorAdSchemaAdmin rejection with reason (10-500 chars)
cancelSponsorAdSchemaCancellation with optional reason
querySponsorAdsSchemaPaginated 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 });
}