Skip to content
Chapter 42Lesson 8

Quiz - Zod 4 - The validation contract

Quiz progress

0 / 0

A public API route handler parses its request body. A client POSTs { email, password, role: 'admin' } against a schema that declares only email and password. With a plain z.object({ email, password }), what happens?

The parse succeeds and role is silently stripped from the output — the contract violation vanishes instead of surfacing. For a request body the senior reach is z.strictObject, which rejects the unknown key.

The parse fails with an unrecognized_keys issue, because z.object rejects any key it didn’t declare.

The parse succeeds and role: 'admin' is forwarded on the output, so downstream code must remember to ignore it.

A codebase is migrating from Zod 3 to Zod 4 and mechanically rewrites every z.string().uuid() to z.uuid(). The IDs come from a third-party system that doesn’t always produce RFC-strict UUIDs. What’s the risk?

v4’s z.uuid() is strict (checks version and variant bits), so it can start rejecting identifiers the loose v3 check accepted. The faithful translation of v3’s loose z.string().uuid() is z.guid().

None — z.uuid() is the exact behavioural equivalent of v3’s z.string().uuid(), just written at the top level.

z.uuid() is looser than the v3 chain, so previously-rejected values now pass and slip malformed IDs through.

You write a single-field check: z.string().refine((value) => value.includes('@'), { error: 'Must contain @' }). A user submits "hello". What does the predicate’s return value mean, and what happens?

The predicate returns false, so the value fails — in a refinement true means acceptable. You describe the passing state, not the problem.

The predicate returns false, so the value passesfalse signals “no error found.”

The predicate returns true, so the value fails — returning true flags the issue described in error.

Each rule needs a home. Sort the four below: a .refine/.transform on the schema, or in the Server Action body after the parse.

On the schema: “password equals its confirmation” and “the email is lowercased and trimmed.” In the action body: “this email isn’t already registered” and “the org slug isn’t taken.”

On the schema: “this email isn’t already registered” and “the org slug isn’t taken.” In the action body: “password equals its confirmation” and “the email is lowercased.”

All four on the schema — Zod refinements can run database lookups through an async predicate, so there’s no reason to split them.

publicUserSchema = userSchema.omit({ passwordHash: true }) is built on a default z.object. A caller still sends an object that includes a passwordHash. What does the parse do?

It passes, stripping passwordHash from the output — .omit removes the field from the shape but doesn’t add a guard against it arriving. The inferred PublicUser type provably has no passwordHash, which is what prevents the leak on the response side.

It fails.omit adds a rule that rejects any input still carrying the omitted key.

It passes and keeps passwordHash on the output, so the omit only affects the type, never the runtime value.

A form schema validates issuedAt with z.iso.datetime().transform((s) => new Date(s)). The <form> sends a string; the action receives a Date. Which inference helper types the form’s contract?

z.input<typeof schema> — the pre-transform shape, where issuedAt is string. z.infer (which equals z.output) would resolve to Date, the wrong type for what the form actually sends.

z.infer<typeof schema> — it always resolves to the shape the parser accepts, which is the string the form sends.

z.output<typeof schema> — the form contract is always the parsed shape, so the output type is what the form should be typed against.

Why is dropping MySchema.parse(input) straight into a Server Action that consumes form data the headline beginner mistake?

parse throws on bad input, so the first user with a malformed field gets a 500 instead of a field-level error. An invalid form is expected, so it should travel the return channel — safeParse — not the throw channel.

parse is slower than safeParse because it builds a full stack trace on every call, even on success.

parse silently coerces unknown keys instead of rejecting them, so the action proceeds on a half-validated object.

A form has an archived checkbox. You reach for z.coerce.boolean(). Why is this the wrong tool, and what’s the consequence?

z.coerce.boolean() runs Boolean(input), which is true for every non-empty string — so it accepts everything and rejects nothing, and would flip a literal "false" to true. The checkbox shape needs z.preprocess((v) => v === 'on' || v === true, z.boolean()).

z.coerce.boolean() throws when the checkbox is unchecked, because the field is absent rather than "off", so the parse 500s on every unchecked submission.

Nothing is wrong — z.coerce.boolean() is the canonical checkbox validator; it maps "on" to true and the absent field to false.

On a boundary, why prefer z.iso.datetime() over z.coerce.date() for an issuedAt that’s meant to be a precise timestamp?

z.coerce.date() runs new Date(input), which happily accepts a date-only "2026-01-15" and silently invents a midnight-UTC time. z.iso.datetime() demands a full timestamp, so an off-contract string is rejected instead of quietly accepted.

z.coerce.date() lets obvious garbage like "not-a-date" through, while z.iso.datetime() rejects it — the difference is invalid input.

z.iso.datetime() returns a Date while z.coerce.date() returns a string, so only the former gives the action the type it needs.

An action validates a new invoice and reaches for one of drizzle-zod’s generators. The invoices table has id and createdAt columns with database defaults. Which generator fits an insert, and what does it do with those defaulted columns?

createInsertSchema — it makes defaulted columns optional, because the database fills them; the caller doesn’t supply them.

createSelectSchema — it’s the canonical write shape, and it keeps id and createdAt required so the insert is fully specified.

createUpdateSchema — it’s the only generator that makes defaulted columns optional, so it’s the right reach for any insert.

Pairing a jsonb column’s schema with the table, you write createInsertSchema(events, { payload: eventPayloadSchema }). The payload column is nullable. What’s the footgun?

This is the direct-schema override form — it replaces the column’s schema and drizzle-zod does not re-apply nullability, so the .nullable() silently vanishes. Re-add it yourself: payload: eventPayloadSchema.nullable().

The override map only accepts a callback (schema) => ..., so passing a schema directly is a type error that won’t compile.

Nothing — drizzle-zod always re-wraps the column’s nullability around an override, whichever form you use.

Quiz complete

Score by topic