Zod from Basics to Advanced: A Complete Guide to All Validation Types
Validation is the foundation of a healthy system. Without good validation, data entering the system can cause bugs, inconsistencies, even security holes. Zod is a TypeScript-first schema validation library that lets you define data structures while automatically getting type inference. This article discusses Zod step by step, from simple primitive types to advanced features like discriminated unions, transforms, and branding, complete with immediately usable code examples.
What Is Zod?
Zod is a library designed for three main things: defining data schemas, performing runtime validation, and generating TypeScript types automatically from defined schemas. Because the schema and types come from the same source, you don’t need to write TypeScript interfaces manually then keep them in sync with the validation logic — they’re always consistent.
The simplest example:
import { z } from "zod";
const schema = z.string();
schema.parse("hello"); // valid
schema.parse(123); // throws an error
If the validated data doesn’t match the schema, Zod throws an error by default. In the next section you’ll see how to handle errors without exceptions using safeParse.
Installation
Zod is installed like a regular npm package:
npm install zod
No additional dependencies are required, and Zod can be used directly in Node.js projects or on the frontend (for example for form validation).
Primitive Types
Validation starts from the most basic data types. Every primitive type in Zod has additional (chained) methods to tighten validation rules.
String
z.string();
Additional validations commonly used:
z.string().min(3);
z.string().max(10);
z.string().length(5);
z.string().email();
z.string().url();
z.string().uuid();
z.string().regex(/^[A-Z]+$/);
z.string().startsWith("A");
z.string().endsWith("Z");
Number
z.number();
Additional number validations:
z.number().min(1);
z.number().max(100);
z.number().int();
z.number().positive();
z.number().negative();
z.number().nonnegative();
z.number().multipleOf(5);
Boolean
z.boolean();
Date
z.date();
Dates can also be given minimum and maximum bounds:
z.date().min(new Date("2024-01-01"));
z.date().max(new Date());
Object Schemas
Object schemas are the most commonly used form, because almost all API payloads are objects with several fields.
const userSchema = z.object({
name: z.string(),
age: z.number(),
});
One of Zod’s main advantages is the ability to generate TypeScript types directly from the schema using z.infer:
type User = z.infer<typeof userSchema>;
With this, the validation schema becomes a single source of truth — you don’t need to define type User separately and risk it being out of sync with the actual validation rules.
Optional, Nullable, and Default Values
Not all fields are required. Zod provides several modifiers for handling optional fields, fields that can be null, or fields with default values.
Optional
Fields may be absent (undefined):
z.string().optional();
Nullable
Fields may be null:
z.string().nullable();
Default Values
Fields are automatically filled with a default value if not provided:
z.string().default("anonymous");
These three modifiers are often combined as needed, for example fields that are optional and also have a default value when not filled.
Array Validation
To validate lists of data, use z.array() with the item schema inside:
z.array(z.string());
Arrays can also be given additional constraints about the number of elements:
z.array(z.string()).min(1);
z.array(z.string()).max(5);
z.array(z.number()).nonempty();
nonempty() ensures the array can’t be empty — useful for cases like an order items list that must contain at least one item.
Enums and Literals
For limited, well-defined values, Zod provides literal and enum.
Literals
A literal validates that the value must be exactly equal to one specific value:
z.literal("admin");
Enums
An enum validates that the value must be one of a defined list:
z.enum(["admin", "user", "guest"]);
This approach pairs well with the enum concept at the database level — the Zod schema becomes the first validation layer before the data is processed further by the application.
Unions and Discriminated Unions
Sometimes a field can accept more than one data type, or the object structure changes depending on a certain field’s value. For these cases, Zod provides union and discriminatedUnion.
Union
A union validates that the value must match one of several schemas:
z.union([z.string(), z.number()]);
Discriminated Union
A discriminated union is used when the object structure changes based on one “discriminator” field:
z.discriminatedUnion("type", [
z.object({ type: z.literal("a"), value: z.string() }),
z.object({ type: z.literal("b"), value: z.number() }),
]);
The following diagram illustrates how Zod decides which schema to use based on the type field’s value:
flowchart TD
A[Data comes in] --> B{Value of the type field?}
B -- "a" --> C[Validate as schema type a, value: string]
B -- "b" --> D[Validate as schema type b, value: number]
B -- other --> E[Error: no matching schema]Compared to a regular union, discriminatedUnion gives clearer error messages and better validation performance, because Zod doesn’t need to try all possible schemas one by one.
Nested Objects
Object schemas can be structured in layers to represent more complex data structures:
const schema = z.object({
user: z.object({
name: z.string(),
address: z.object({
city: z.string(),
}),
}),
});
Each nested object level still gets the same validation and type inference as a regular object schema.
Records (Dynamic Key Objects)
If the object structure has dynamic keys — for example objects whose keys are IDs or codes not known in advance — use z.record():
z.record(z.string());
You can also specify the key and value types separately:
z.record(z.string(), z.number());
This pattern suits cases like a { [productId: string]: number } mapping representing stock quantity per product.
Refinements (Custom Validation)
Not all validation rules can be expressed with built-in methods. For custom validation logic, Zod provides refine() and superRefine().
refine()
refine() suits simple validation on a single field:
z.string().refine((val) => val.includes("@"), {
message: "Must contain @",
});
superRefine()
superRefine() suits validation involving several fields at once, for example ensuring two fields match:
z.object({
password: z.string(),
confirmPassword: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Passwords don't match",
path: ["confirmPassword"],
});
}
});
superRefine gives access to ctx, so you can add errors to specific fields (path) with specific messages.
Transforms and Preprocess
Besides validating, Zod can also change data before or after validation.
Transform
transform() changes data after validation succeeds:
z.string().transform((val) => val.trim());
Another example, converting a string to a number after validating it as a string:
z.string().transform((val) => Number(val));
Preprocess
preprocess() modifies input before validation runs:
z.preprocess((val) => Number(val), z.number());
This pattern is very suitable for HTML form inputs, where all inputs are essentially strings even though semantically they should be numbers.
Intersections
To combine two schemas into one, use z.intersection():
const a = z.object({ name: z.string() });
const b = z.object({ age: z.number() });
const merged = z.intersection(a, b);
The merged result will validate an object that must satisfy both schemas at once — having a name field of type string and an age field of type number.
Partial, Pick, Omit, and Extend
Already-defined object schemas can be modified without rewriting from scratch, using several built-in methods.
Partial
Makes all fields optional:
userSchema.partial();
Pick
Takes only some fields:
userSchema.pick({ name: true });
Omit
Removes certain fields:
userSchema.omit({ age: true });
Extend
Adds new fields to an existing schema:
userSchema.extend({ role: z.string() });
These methods are very helpful for maintaining consistency between related schemas, for example a schema for creating a user (without id) and a schema for updating a user (all fields optional).
Strict vs Passthrough
By default, Zod ignores additional properties not defined in the schema. This behavior can be changed with strict() or passthrough().
Strict
Rejects additional properties — validation fails if there are unknown fields:
z.object({ name: z.string() }).strict();
Passthrough
Allows additional properties to remain in the validation result:
z.object({ name: z.string() }).passthrough();
Choose strict() when you want to make sure the payload truly matches the contract, for example on public API endpoints. Use passthrough() when additional fields are indeed expected, for example when the schema only validates part of a larger object.
Safe Parse
The parse() method throws an error when validation fails, which means you must wrap it in a try/catch. As an alternative, safeParse() always returns a result object without throwing an exception:
const result = schema.safeParse(data);
if (!result.success) {
console.log(result.error);
}
// ANTI-PATTERN: parse() at the boundary layer without try/catch can crash the request
const data = schema.parse(input);
// CORRECT: safeParse() at the boundary layer, errors handled explicitly
const result = schema.safeParse(input);
if (!result.success) {
return { status: 400, error: result.error.format() };
}
const data = result.data;
safeParse() is more suitable at boundary layers like API controllers or services, where validation failure is a normal scenario to be handled with an error response, not an unhandled exception.
Error Handling and Custom Messages
Every validation method can be given a custom error message as the second parameter:
z.string().min(3, { message: "Minimum 3 characters" });
This custom message will appear in result.error when validation fails, so it can be directly displayed to users without manually mapping error codes. Besides per-field custom messages, Zod also supports a global error map for consistently adjusting error message format across the entire application.
Async Validation
Some validations require asynchronous operations, for example checking whether a username is already registered in the database. For this case, refine() can accept an async function:
z.string().refine(async (val) => {
const exists = await checkUser(val);
return !exists;
}, {
message: "User already exists",
});
Schemas containing async refinements cannot be validated with regularparse()orsafeParse(). UseparseAsync()orsafeParseAsync(), or the validation will fail unexpectedly.
Branding and Nominal Typing
By default, TypeScript uses structural typing — two types with the same structure are considered equivalent even if semantically different (for example UserId and ProductId, both strings). Zod provides brand() to create nominal typing, where those types can’t be accidentally swapped:
const UserId = z.string().brand("UserId");
This feature is useful in large systems with many different IDs that are structurally all strings, but domain-wise must not be interchangeable.
When Are Built-in Validations Enough, When Is Refine or a Separate Schema Needed?
Not all validation cases need advanced features. The following diagram helps determine the appropriate approach:
flowchart TD
A{Does the validation involve only one field?} -- Yes --> B[Use built-in methods: min, max, regex, etc.]
A -- No --> C{Involves several fields at once?}
C -- Yes --> D[Use superRefine]
C -- No --> E{Structure changes based on one field?}
E -- Yes --> F[Use discriminatedUnion]
E -- No --> G[Use refine or transform as needed]Zod Usage Best Practices
DO:
✓ separate domain schemas from form schemas
✓ use discriminatedUnion for complex structures
✓ leverage z.infer so types are always in sync with the schema
✓ use safeParse at boundary layers (API, services)
AVOID:
✗ validation logic in the UI separate from the schema
✗ parse() directly at the boundary layer without error handling
✗ schema duplication for structures that are actually the same
Separating domain schemas (the actual data representation) from form schemas (the raw input representation from users) helps keep validation clean, especially when forms use preprocess to convert strings to numbers or booleans before validating as a domain schema.
Summary
- Zod defines schemas, performs runtime validation, and generates TypeScript types automatically via
z.infer.- Primitive types (
string,number,boolean,date) have chained methods to tighten rules, like.min(),.max(),.email().optional(),nullable(), anddefault()handle fields that aren’t required or have built-in values.discriminatedUnionis more appropriate than a regularunionwhen the object structure changes based on one field.refine()for custom single-field validation,superRefine()for validation involving several fields.transform()changes data after validation,preprocess()changes input before validation — suitable for HTML forms.partial(),pick(),omit(), andextend()help derive new schemas from existing ones without duplication.- Use
safeParse()(notparse()) at boundary layers like APIs or services so validation errors can be handled explicitly.- Async validation must use
parseAsync()orsafeParseAsync().