Basic Zod examples validate a string or an object. Real APIs also receive query parameters, environment variables, multiple command shapes, and payloads that evolve over time. A few patterns make those boundaries much clearer.
Reject unknown fields intentionally#
const updateProfileSchema = z
.object({
displayName: z.string().trim().min(2).max(60).optional(),
timezone: z.string().min(1).optional(),
})
.strict();
Strict objects catch display_name when the API expects displayName. They also prevent a caller from passing fields such as role into a general update endpoint.
Coerce only at text boundaries#
Query strings and environment variables arrive as strings:
const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
includeArchived: z
.enum(["true", "false"])
.transform((value) => value === "true")
.default(false),
});
Broad boolean coercion is surprising because JavaScript treats many non-empty strings as truthy. An explicit enum makes accepted values obvious.
Model alternatives with a discriminator#
When a command has several valid shapes, a discriminated union gives precise validation and narrowing:
const destinationSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("email"), address: z.string().email() }),
z.object({ kind: z.literal("push"), deviceToken: z.string().min(1) }),
]);
type Destination = z.infer<typeof destinationSchema>;
Code switching on kind now receives the correct fields without casts.
Validate configuration at startup#
const environmentSchema = z.object({
MONGODB_URI: z.string().url(),
JWT_AUDIENCE: z.string().min(1),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
export const environment = environmentSchema.parse(process.env);
Failing during startup is better than discovering a missing variable halfway through a request.
Use transforms sparingly. A schema that performs hidden business work becomes hard to understand. Let schemas normalize boundary representation; let use cases make business decisions. With inferred types, the validator and TypeScript contract cannot quietly drift apart.