Literals
const literalSchema = z.literal('hello');
literalSchema.parse('hello'); // Valid
// literalSchema.parse("world"); // Throws ZodError: Expected literal 'hello', received 'world'
z.enum()
for Fixed Sets
When you only allow a few valid string values, define them in an enum schema.
Example
const colorEnum = z.enum(['RED', 'GREEN', 'BLUE']);
// passes
colorEnum.parse('RED');
// fails
colorEnum.parse('PURPLE');
Why Not Use .literal()
Union?
z.enum(["RED", "GREEN", "BLUE"])
is more direct for small sets of strings.- TS can also interpret it as a union of those string literals.
- If you have a large set, union is equally valid, just more verbose.
Tuples
const tupleSchema = z.tuple([z.string(), z.number()]);
tupleSchema.parse(['hello', 123]); // Valid
// tupleSchema.parse([123, "hello"]); // Throws ZodError: Expected string, received number at index 0
// tupleSchema.parse(["hello"]); // Throws ZodError: Expected tuple to have 2 elements, but got 1
Unions
const stringOrNumberSchema = z.union([z.string(), z.number()]);
stringOrNumberSchema.parse('hello'); // Valid
stringOrNumberSchema.parse(123); // Valid
// stringOrNumberSchema.parse(true); // Throws ZodError: Expected string | number, received boolean
Intersections
const stringSchema = z.object({ a: z.string() });
const numberSchema = z.object({ b: z.number() });
const intersectionSchema = z.intersection(stringSchema, numberSchema);
intersectionSchema.parse({ a: 'hello', b: 123 }); // Valid
// intersectionSchema.parse({ a: "hello" }); // Throws ZodError: Required at 'b'
Reusable Schema Components with Unions
Create reusable schema components by defining schemas as variables or functions. This promotes modularity and reduces code duplication.
const baseUserSchema = z.object({
id: z.number().positive(),
createdAt: z.date(),
});
const customerSchema = baseUserSchema.extend({
customerType: z.literal('customer'),
orders: z.array(z.object({ orderId: z.string() })),
});
const adminSchema = baseUserSchema.extend({
customerType: z.literal('admin'),
permissions: z.array(z.string()),
});
const userSchema = z.union([customerSchema, adminSchema]); // Combine reusable schemas
Discriminated Unions
When your data has a known “discriminator” field, z.discriminatedUnion()
is more efficient (and more explicit) than normal unions.
Example: type
or kind
as the Discriminator
import { z } from 'zod';
const customerSchema = z.object({
type: z.literal('customer'),
orders: z.array(z.string()),
});
const adminSchema = z.object({
type: z.literal('admin'),
permissions: z.array(z.string()),
});
const userSchema = z.discriminatedUnion('type', [customerSchema, adminSchema]);
// Succeeds if type === 'customer' or 'admin'
userSchema.parse({
type: 'customer',
orders: ['order1', 'order2'],
});
Benefits
- Performance: Zod can skip checking every union branch once it sees the discriminator.
- Clarity: If your input object’s
type
orkind
is incorrect, you’ll get an immediate error.
never
, unknown
, and any
Zod includes special schemas that mirror TypeScript’s built-in utility types:
z.never()
— always fails. If you seez.never()
, that property can never validly exist.z.unknown()
— anything can pass, but the final type isunknown
. Good for storing data you won’t interact with directly.z.any()
— anything can pass and the final type isany
. Rarely recommended, but can be a last resort if you absolutely can’t type something.
Example
const exoticSchema = z.object({
no: z.never(), // always invalid
hidden: z.unknown(), // always valid, type = unknown
whatever: z.any(), // always valid, type = any
});
Pro Tip: Lean on strong typing as much as possible. z.unknown()
and z.any()
can be necessary but generally reduce type safety.
Strict vs. Passthrough vs. Strip
When validating objects, Zod handles extra/unknown keys differently depending on the mode:
Strip (default)
Strict
Passthrough
Example
const baseObj = z.object({ name: z.string() });
// Strict
const strictObj = baseObj.strict();
strictObj.parse({ name: 'Zod', age: 99 });
// => throws: unrecognized key "age"
// Passthrough
const passObj = baseObj.passthrough();
passObj.parse({ name: 'Zod', age: 99 });
// => { name: "Zod", age: 99 }
// Default (strip)
baseObj.parse({ name: 'Zod', age: 99 });
// => { name: "Zod" } (age is stripped out)
Use the mode that best suits your data. Strict is good when you want to ensure no unexpected fields slip in; passthrough is handy if you want to keep them for logging or debugging.