Zod Showcase

Common Pitfalls

Mistakes that trip up almost every Zod beginner. Understanding these early saves you hours of debugging later.

.parse() Throws (and Crashes Your App)

.parse() throws a ZodError the moment validation fails. If nothing catches it, that exception bubbles straight up and takes down your request handler, your script, or your whole app. This demo runs the same schema through .safeParse() instead, so a bad input shows up as a result instead of a crash. Try fixing the input below to see the success path.

Schema
import { z } from 'zod';

const schema = z.object({ name: z.string(), age: z.number() });

// Throws a ZodError. If nothing catches it, your app crashes.
const data = schema.parse(input);
Inferred Type
type Schema = {
  name: string;
  age: number;
}
// If age is the wrong type, schema.parse(input)
// throws immediately. No try/catch, no survivors.
Output
  • age Invalid input: expected number, received string

partial() vs Required Fields

.partial() makes every field optional, which is useful for PATCH endpoints but easy to misapply when you need all fields. The schema below uses .partial(). Try submitting an empty object {}. It passes even though the original schema requires name, email, and age.

Schema
import { z } from 'zod';

const UserSchema = z.object({
  name: z.string(),
  email: z.email(),
  age: z.number(),
});

// Common mistake: using .partial() for a full create
const CreateUserSchema = UserSchema.partial();
// All fields are now optional. {} is valid!

// Use .partial() only for updates (PATCH)
const UpdateUserSchema = UserSchema.partial();
// Use the original for creates (POST)
const FullUserSchema = UserSchema;
Inferred Type
// After .partial():
type PartialUser = {
  name?: string | undefined;
  email?: string | undefined;
  age?: number | undefined;
}
Output
{}

Type Inference Gotcha: Coercion

Both schemas below infer the exact same TypeScript type: { count: number }. But at runtime they behave very differently. z.number() only accepts an actual number. z.coerce.number() wraps the input in Number() first, so a string like "42" becomes 42 before validation runs. The input starts as a string, and notice how it parses successfully. Try changing "42" to "abc" to see coercion fail, or remove the quotes to see both schemas accept it. The Gotcha is that null, true, false, [], and other weird values all coerce to a number without throwing an error, which can lead to surprising results if you use coercion without realizing it.

Schema
import { z } from 'zod';

// Fails for "42". The schema expects a number, not a string
const StrictSchema = z.object({
  count: z.number(),
});

// Coerces "42" → 42 before validating
const CoercedSchema = z.object({
  count: z.coerce.number(),
});

// z.coerce wraps the value in Number(), String(), etc.
// Use it when input comes from FormData, URLSearchParams,
// or any source that serializes everything as strings.
Inferred Type
// Both schemas infer the identical type:
type StrictSchema = {
  count: number; // only accepts actual numbers at runtime
};

type CoercedSchema = {
  count: number; // accepts "42", true, false, [], null at runtime
};
Output
{
  "count": 42
}

.parse() vs Async .refine()

.refine() can run an async check, like looking up a username in a database. But .parse() and .safeParse() are both synchronous, neither can wait for a Promise, so both throw the moment a refine is async. Flip the toggle below to switch to .safeParseAsync(), the one method that actually waits. Same schema, same input, only the method call changes. Try fixing the username afterward to see a real success.

Schema
import { z } from 'zod';

const UsernameSchema = z.object({
  username: z.string().min(3),
}).refine(async (data) => {
  // Imagine this hits your database
  const taken = await isUsernameTaken(data.username);
  return !taken;
}, { error: 'Username is already taken', path: ['username'] });
Inferred Type
// The inferred type is identical either way:
type Username = {
  username: string;
};
// The async refine changes nothing about the type.
// It only changes which method you're allowed to call.
Output

💥 Threw: