Zod as Your Application's Validation Boundary
How to use Zod as a runtime validation boundary between trusted internal code and untrusted external data — inputs, API responses, and localStorage reads.
Zod is most powerful at system boundaries — the points where external or persisted data enters your application. This article covers validation patterns for localStorage, URL parameters, JSON imports, and API responses, along with discriminated union schemas, partial schemas for backward-compatible migration, and error normalization strategies.
Contents
The Boundary Problem
TypeScript's type system is a compile-time guarantee. At runtime, data from the outside world — a network response, a localStorage read, a URL query string — has no types. It's just bytes. Your type annotations are a promise you made to the compiler, not a verification of the actual data.
Zod bridges this gap. It validates data at runtime and narrows TypeScript types based on the validation result. Used correctly, it makes your type annotations true — not just assumed.
The rule: validate at every boundary where external or persisted data enters your application's trust boundary. Never assume. Validate, parse, or reject.
localStorage Validation
localStorage is the most commonly skipped validation boundary. Application code writes to it in a known format, so reading it back feels safe. It isn't. Users can modify localStorage from DevTools. Application code changes across deployments, so the schema you wrote last month may not match what's stored in a user's browser today.
import { z } from 'zod';
const UserPreferencesSchema = z.object({
theme: z.enum(['dark', 'light', 'system']).default('system'),
locale: z.enum(['en', 'es']).default('en'),
sidebarCollapsed: z.boolean().default(false),
lastVisited: z.string().datetime().optional(),
});
type UserPreferences = z.infer<typeof UserPreferencesSchema>;
function loadPreferences(): UserPreferences {
try {
const raw = localStorage.getItem('user-preferences');
if (!raw) return UserPreferencesSchema.parse({}); // triggers .default() on all fields
const parsed = JSON.parse(raw);
const result = UserPreferencesSchema.safeParse(parsed);
if (!result.success) {
console.warn('Preferences schema mismatch, resetting to defaults:', result.error.issues);
return UserPreferencesSchema.parse({});
}
return result.data;
} catch {
return UserPreferencesSchema.parse({});
}
}
Use safeParse (not parse) at system boundaries. safeParse returns a result object rather than throwing — this keeps error handling explicit. When validation fails, fall back to defaults and optionally clear the stale data. Do not throw into the user's face because their old browser session has a deprecated schema.
The .default() method on Zod fields handles missing keys during a schema migration without requiring explicit handling at every call site. This is the correct pattern for backward-compatible schema evolution: add new fields with defaults, deprecate old fields with .optional(), remove them in a future version.
URL Parameter Validation
URL parameters are user-controlled strings. Never trust them without validation:
const SearchParamsSchema = z.object({
q: z.string().min(1).max(200).optional(),
page: z.coerce.number().int().positive().default(1),
category: z.enum(['essays', 'tutorials', 'deep-dives', 'research']).optional(),
sort: z.enum(['date', 'relevance']).default('date'),
});
function parseSearchParams(params: Record<string, string | string[] | undefined>) {
// Flatten arrays (Next.js URLSearchParams can return string[])
const flat = Object.fromEntries(
Object.entries(params).map(([k, v]) => [k, Array.isArray(v) ? v[0] : v])
);
const result = SearchParamsSchema.safeParse(flat);
return result.success ? result.data : SearchParamsSchema.parse({});
}
Note z.coerce.number() for the page parameter. URL params are always strings; coercion handles the "1" → 1 conversion automatically. Without coercion, z.number() would fail validation on string inputs even when the string represents a valid number.
JSON Import Validation
When users can import JSON files — admin state exports, configuration files, data imports — validate the entire structure before merging it into application state:
async function importAdminState(file: File): Promise<Result<AdminState, string>> {
try {
const text = await file.text();
const json = JSON.parse(text);
// Use the full schema (not .partial()) for imports
const result = AdminStateSchema.safeParse(json);
if (!result.success) {
const errors = result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ');
return { ok: false, error: `Invalid import format: ${errors}` };
}
return { ok: true, value: result.data };
} catch (e) {
return { ok: false, error: 'File is not valid JSON' };
}
}
For imports, use the full schema rather than .partial(). An import should be complete — if it's missing required fields, that's user error and should be reported clearly. Contrast with localStorage loading, where partial data is expected during schema migration and should be gracefully defaulted.
Discriminated Union Schemas
When a schema models multiple distinct variants, use z.discriminatedUnion() rather than nested z.union(). Discriminated unions use a common literal field to select the correct schema branch, which is significantly faster and produces much better error messages:
const ActionSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('SET_THEME'),
payload: z.enum(['dark', 'light', 'system']),
}),
z.object({
type: z.literal('UPDATE_CONFIG'),
payload: z.object({
siteTitle: z.string().min(1).max(100),
siteDescription: z.string().max(300),
}),
}),
z.object({
type: z.literal('RESET'),
// No payload needed
}),
]);
type Action = z.infer<typeof ActionSchema>;
The inferred Action type is a proper discriminated union that TypeScript can narrow in switch statements. The Zod schema and the TypeScript type stay in sync automatically — change one and the other updates.
Partial Schemas for Migration
When loading persisted state across schema versions, the loaded data may be missing fields that are new to the current schema. .partial() makes every field optional for validation purposes, then .parse({}) fills in defaults:
// Schema at current version
const AdminStateSchema = z.object({
theme: z.enum(['dark', 'light', 'system']).default('system'),
sidebar: z.object({
collapsed: z.boolean().default(false),
width: z.number().default(240),
}),
// New in v2 — users on v1 won't have this
analytics: z.object({
enabled: z.boolean().default(true),
}).default({ enabled: true }),
});
function loadState(): AdminState {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return AdminStateSchema.parse({});
const json = JSON.parse(raw);
// .partial() allows missing fields
// .parse() on the result fills in .default() values
const partial = AdminStateSchema.partial().safeParse(json);
if (!partial.success) return AdminStateSchema.parse({});
// Re-parse with the full schema to fill in any missing defaults
return AdminStateSchema.parse(partial.data);
}
Error Normalization
Zod errors are detailed but noisy when surfaced directly to users. Normalize them at the validation boundary:
function formatZodError(error: z.ZodError): string {
return error.issues
.map(issue => {
const path = issue.path.length > 0 ? `${issue.path.join('.')}: ` : '';
return `${path}${issue.message}`;
})
.join(', ');
}
// Usage in a form submission handler
const result = ContactFormSchema.safeParse(formData);
if (!result.success) {
setError(formatZodError(result.error));
return;
}
For API responses in a client-server setup, return Zod issue arrays in a stable format that the client can map to field-level error states. The issue.path array gives you the exact field path the error applies to — use it to wire errors to the correct form fields.
Integrating with TypeScript Discriminated Unions
Zod's inferred types compose cleanly with hand-written TypeScript. When you need to add method-like behavior or complex type constraints that Zod can't express, define the TypeScript type independently and use z.ZodType<YourType> as the schema's type parameter:
type ContentItem = {
id: string;
type: 'article' | 'project' | 'resource';
title: string;
slug: string;
};
const ContentItemSchema: z.ZodType<ContentItem> = z.object({
id: z.string().uuid(),
type: z.enum(['article', 'project', 'resource']),
title: z.string().min(1),
slug: z.string().regex(/^[a-z0-9-]+$/),
});
This creates a Zod schema that TypeScript guarantees produces a ContentItem value. If the schema's inferred type diverges from ContentItem, you get a compile-time error.
The takeaway: use Zod for runtime validation at boundaries, TypeScript for compile-time contracts throughout the application interior. They're complementary, not competing. The boundary is where both meet.