43.2 Defining Schemas with Zod: Primitives, Objects, Arrays, and Unions
Alright, let’s get our hands dirty with Zod. Forget abstract theory; we’re here to build something that works. The core idea is simple but profound: you define a schema—a set of rules for what your data should look like—and Zod uses it for two magical things: 1) validating unknown data at runtime (the “is this even safe to use?” check), and 2) inferring a static TypeScript type from it (so your editor knows exactly what you’re working with). It’s the bridge between the wild west of runtime and the orderly kingdom of compile time.
We’ll start with the atoms and build up to the molecules.
The Primitives: Your Basic Building Blocks
This is where it all begins. Zod’s primitive schemas are deceptively simple but pack a punch. You’ll use these for every single string, number, or boolean you care to validate.
import { z } from 'zod';
// A string. But not just *any* string. A validated one.
const usernameSchema = z.string();
// ^^ The inferred type here is: string
// Let's validate some data, shall we?
const result = usernameSchema.safeParse(42); // This is, clearly, nonsense.
if (!result.success) {
console.log(result.error.flatten());
// Outputs a nice, structured error:
// {
// formErrors: [],
// fieldErrors: {
// _errors: ["Expected string, received number"]
// }
// }
} else {
// TypeScript now knows `result.data` is a string.
console.log(`Hello, ${result.data}`);
}
But why stop at just string? Zod primives come with built-in refinements. You can chain them to create meaningfully constrained data.
// A string that is also an email? More likely than you think.
const emailSchema = z.string().email();
// A number that isn't utterly depressing?
const positiveAgeSchema = z.number().positive().int();
// A string that actually has content, not just whitespace?
const nonEmptyStringSchema = z.string().min(1);
The beauty here is the self-documenting code. z.string().email() is infinitely clearer than some cryptic regex variable named EMAIL_REGEX that you found in a utils file from 2017.
Objects: Where the Real Magic Happens
Primitives are cute, but data in the real world comes in objects. This is Zod’s killer feature. You define the shape once, and get validation and type safety.
const UserSchema = z.object({
username: z.string().min(3),
email: z.string().email(),
age: z.number().int().positive().optional(), // Because not everyone wants to tell us.
isActive: z.boolean().default(true), // A sensible default? What a concept.
});
// TypeScript infers this entire type for free. Look ma, no interfaces!
type User = z.infer<typeof UserSchema>;
/*
This is equivalent to:
type User = {
username: string;
email: string;
age?: number;
isActive: boolean;
}
*/
// Now let's validate some garbage API response
const apiData = {
username: 'jd',
email: 'not-an-email',
isActive: 'yes' // A classic API "gotcha"
};
const parsedUser = UserSchema.safeParse(apiData);
if (!parsedUser.success) {
console.log("Validation failed spectacularly:", parsedUser.error.issues);
} else {
// parsedUser.data is now a fully validated, type-safe User object.
// Note: `age` is undefined, and `isActive` is true (the default was applied).
console.log(`Welcome back, ${parsedUser.data.username}`);
}
The .safeParse method is your best friend. It returns a discriminated union, so you can cleanly handle success and failure without try/catch blocks. Use .parse if you want it to throw an exception, but I find that gets messy fast.
Arrays: Because One of a Thing is Never Enough
Validating a single object is great. Validating an array of them is how you sleep soundly at night after fetching from a /users endpoint.
const UserArraySchema = z.array(UserSchema); // Reuse the schema we already built!
const messyData = [
{ username: 'alice', email: 'alice@example.com' },
{ username: 'bob', email: 'invalid' }, // Oh no, Bob.
{ notEven: 'a real object' }
];
const result = UserArraySchema.safeParse(messyData);
if (!result.success) {
// The error will precisely tell you that index 1 has an invalid email and index 2 is unrecognized.
console.log("The API has failed us. Specifically, on these indices:", result.error.issues);
}
You can also define arrays inline with z.array(z.string()), but the real power is composing them from your existing object schemas. It’s like LEGO for your data types.
Unions: Embracing the Chaos
Sometimes, data is messy. Sometimes a field can be a string or a number. Sometimes you’re dealing with a legacy system where a “status” is either the number 1 or the string "active" because of course it is. This is where unions save your sanity.
// The classic "ID that could be a number or a string" problem
const IDSchema = z.union([z.string(), z.number()]).transform(val => String(val));
// We use `.transform` to normalize it to a string after validation.
// A more literal example: a terrible API design
const StatusSchema = z.union([
z.literal(1),
z.literal('active'),
z.literal(0),
z.literal('inactive')
]);
// The inferred type is: 1 | "active" | 0 | "inactive"
// A more elegant way to write the same thing: z.enum
const BetterStatusSchema = z.enum(['active', 'inactive']);
The union is validated in order. Zod will try the first schema, and if that fails, it moves to the next, until it either finds a match or runs out of options and tells you exactly why everything was wrong. It’s your first line of defense against backend developers who think using different types for the same concept is “flexible.”