Skip to content
Chapter 42Lesson 6

Crossing the FormData boundary

How Zod coercion and preprocessing turn the string-only values a browser form submits into the typed, validated data your server code works with.

A user fills in a form and hits submit. On the server, a function reads formData.get('quantity') and gets back the string "3", not the number 3. formData.get('archived') comes back as "on", or as nothing at all. formData.get('issuedAt') is an ISO string like "2026-01-15T10:30:00Z", not a Date. But the code on the server wants a number, a boolean, a Date: the typed domain you’ve spent five lessons learning to describe with Zod. The builders, the formats, the checks, and the derived shapes all describe that typed domain. The wire between the browser and the server carries none of it. It carries strings.

This lesson is the bridge across that gap. By the end you’ll write invoiceFormSchema, the form-input schema every action in this unit reuses, along with the one line that turns a bag of strings into a validated, typed object: safeParse(Object.fromEntries(formData)). You’ll also learn the four places where the obvious version of that bridge silently corrupts your data, and how to recognize each one before you reach for the wrong tool.

Start with the constraint everything else works around. When a browser submits a <form>, it serializes every control to text and sends it across. The server reconstructs a FormData object, a key/value collection that mirrors the form, and every value you pull out of it has the same shape:

// <form>
// <input name="quantity" type="number" />
// <input name="archived" type="checkbox" />
// <input name="issuedAt" type="datetime-local" />
// </form>
const quantity = formData.get('quantity'); // FormDataEntryValue → "3"
const archived = formData.get('archived'); // FormDataEntryValue → "on" | null
const issuedAt = formData.get('issuedAt'); // FormDataEntryValue → "2026-01-15T10:30:00Z"

Look at the type the platform hands you: FormDataEntryValue, which is string | File. There is no number on the wire, no boolean, no Date, no array. A number input that looks like it holds a number gives you "3". The checkbox gives you "on" or nothing. The date picker gives you an ISO string. This isn’t a Zod fact, it’s the HTML form specification. A <form> can only carry text, plus one other thing: files.

Hold a picture of this in your head. On the left is a browser form with controls that look typed: a number spinner, a checkbox, a date picker. They cross the wire. On the right is the server, holding the same fields as quoted strings.

Browser — <form>

quantity 3
archived checked
issuedAt 2026-01-15 10:30
notes net 30 terms
customerId Acme Corp

Server — formData

quantity "3"
archived "on"
issuedAt "2026-01-15T10:30:00Z"
notes "net 30 terms"
customerId "550e8400-…"
A browser <form> can only carry text. Every typed-looking control on the left arrives on the server as a quoted string — the schema, straddling the divider, is where those strings become typed again.

Two more shapes are worth naming before moving on, since they get their own sections later. A field can appear more than once: a multi-select, or several checkboxes that share one name. For those, formData.getAll(name) returns a string[] with every value, in order. And a file input on a multipart/form-data form gives you a File, the one value off a form that isn’t a string. Keep those two in mind; the rest of this lesson assumes everything else is text.

The first move: Object.fromEntries(formData)

Section titled “The first move: Object.fromEntries(formData)”

You could call formData.get field by field, but that means unpacking the form by hand before Zod ever sees it. The schema already knows the field names, so let it do the unpacking. FormData is iterable as [key, value] pairs, so Object.fromEntries turns the whole thing into a plain object in one line:

const raw = Object.fromEntries(formData);
// { quantity: '3', archived: 'on', notes: 'net 30 terms' }

That raw object, with every value still a string, is exactly what you’ll hand to safeParse. One call collects the entire form, and the schema then validates and coerces the whole thing in a single pass. This is the shape every form-consuming function in this unit opens with, so it’s worth committing to memory.

Object.fromEntries has one sharp edge, though, and it’s the first of this lesson’s four traps. The edge comes from fromEntries itself, not from Zod, which is why it bites this early. When a key appears more than once, Object.fromEntries keeps only the last value, and every earlier value is silently dropped.

const raw = Object.fromEntries(formData);
// tags: 'paid' ← only the LAST checked value survived

This looks complete, but tags was checked twice and fromEntries collapsed both values to the last one. The schema’s z.array(z.string()) receives a single string 'paid' instead of an array, and fails with a confusing “expected array, received string.” The bug is upstream of Zod, so the schema isn’t the thing at fault.

The fix is mechanical: spread Object.fromEntries for the ordinary single-value fields, then override each multi-valued key with formData.getAll. The cost is that you have to know which fields are multi-valued, since nothing detects them for you. The convention that saves you is to name those inputs deliberately, with a plural like tags, and to remember that any field whose schema is a z.array(...) needs a getAll. Forget it, and the schema receives a single string where it wanted an array, while the error message points at the schema even though the real bug is here, two lines up.

z.coerce: a transform that runs a constructor

Section titled “z.coerce: a transform that runs a constructor”

Now the bridge itself. You have a raw object full of strings, and the schema wants numbers and dates. The tool is z.coerce, and it isn’t a new idea: you already met it in Checks and transforms, you just didn’t have a name for it.

Recall what a transform does: it runs a function on the input before the inner schema validates, and it changes the inferred output type. z.coerce is exactly that, with the transform function fixed to a JavaScript constructor. z.coerce.number() is z.number() with a built-in transform that calls Number(input) first. The chain is the same every time:

  • z.coerce.number() runs Number(input), then validates a number.
  • z.coerce.date() runs new Date(input), then validates a date.
  • z.coerce.bigint() runs BigInt(input), then validates a bigint.
  • z.coerce.string() runs String(input), then validates a string.

The input is accepted as unknown, the constructor runs, and the inner schema checks the result. That last detail matters: any .positive() or .min() you chain validates the coerced value, not the original string.

This is also where the input/output type split from Derive, don’t duplicate stops being abstract. A z.coerce.number() schema has input unknown and output number. The form sends a string, the parsed value is a number, and the two types are genuinely different. This is exactly the case z.input and z.output exist for. The form contract, meaning what the <form> is allowed to send, is z.input<typeof schema>. The validated value your code works with afterward is z.output<typeof schema>, which is what z.infer gives you. Coercion is the reason a form schema has two distinct types.

Here’s the schema this whole unit is built on. Read it one field at a time.

const invoiceFormSchema = z.object({
customerId: z.uuid(),
total: z.coerce.number().positive().multipleOf(0.01),
issuedAt: z.coerce.date(),
notes: z.string().optional(),
});
type InvoiceFormInput = z.input<typeof invoiceFormSchema>;

The object is the contract between the form’s name attributes and the typed input your code receives. Each key is a field name; each value is the rule for that field. The form’s <input name="..."> attributes must match these keys exactly.

const invoiceFormSchema = z.object({
customerId: z.uuid(),
total: z.coerce.number().positive().multipleOf(0.01),
issuedAt: z.coerce.date(),
notes: z.string().optional(),
});
type InvoiceFormInput = z.input<typeof invoiceFormSchema>;

customerId is a string that stays a string. IDs already arrive as text off the wire, so there’s nothing to coerce: z.uuid() only validates the format. Not every field needs coercion, only the ones whose target type isn’t a string.

const invoiceFormSchema = z.object({
customerId: z.uuid(),
total: z.coerce.number().positive().multipleOf(0.01),
issuedAt: z.coerce.date(),
notes: z.string().optional(),
});
type InvoiceFormInput = z.input<typeof invoiceFormSchema>;

total is the central coercion. z.coerce.number() runs Number on the incoming "49.99" to get 49.99, and then .positive() and .multipleOf(0.01) validate that number: a positive amount with at most two decimal places. The checks run on the coerced value, never the string.

const invoiceFormSchema = z.object({
customerId: z.uuid(),
total: z.coerce.number().positive().multipleOf(0.01),
issuedAt: z.coerce.date(),
notes: z.string().optional(),
});
type InvoiceFormInput = z.input<typeof invoiceFormSchema>;

issuedAt runs new Date on the ISO string, so the output type here is Date. This exact line hides a trap that you’ll fix later in the lesson, when you’ll see why it’s too loose about format.

const invoiceFormSchema = z.object({
customerId: z.uuid(),
total: z.coerce.number().positive().multipleOf(0.01),
issuedAt: z.coerce.date(),
notes: z.string().optional(),
});
type InvoiceFormInput = z.input<typeof invoiceFormSchema>;

InvoiceFormInput is the form-side type from z.input, the shape the form is allowed to send, where total and issuedAt are still strings. It is deliberately different from z.infer, the output, where they’re a number and a Date. When you wire this to a <form>, this input type is the contract.

1 / 1

The line every form-consuming function in this unit opens with combines those two moves into one:

const parsed = invoiceFormSchema.safeParse(Object.fromEntries(formData));

Object.fromEntries collects the form into a string-valued object, and safeParse coerces and validates it against the schema. After this line, parsed is either a success holding a fully typed invoice or a failure holding the issues. The parsed.success branch, the error rendering, and the database write all come next chapter, where you wire this schema into an actual Server Action. For now, every snippet stops right here, at the safeParse. This one line is the whole reason the lesson exists: it’s the seam where untrusted strings become trusted, typed data.

Try it live. The schema is prefilled, so you can watch coercion succeed on a valid string-shaped invoice, succeed with conversion on total: "12.5", and fail cleanly on total: "abc".

That’s the happy path, and it really is three lines. If JavaScript’s coercion rules matched HTML’s wire format, the lesson would end here. They don’t, and there are four places where the obvious code does something quietly wrong. The rest of the lesson works through each one.

The boolean trap: z.coerce.boolean() is wrong for checkboxes

Section titled “The boolean trap: z.coerce.boolean() is wrong for checkboxes”

A checkbox is the most common boolean on any form, and the obvious schema for it, z.coerce.boolean(), is wrong in a way that’s worse than throwing: it never fails.

Start with the wire shape, precisely, because the danger comes from it. A checked checkbox sends name=on. An unchecked checkbox sends nothing at all: the key is simply absent from the FormData, so after Object.fromEntries the field is undefined. Not "off", not "false", not "". The checkbox is either present or absent, never present with a false value.

Now consider z.coerce.boolean(). Its transform calls Boolean(input), and Boolean treats every non-empty string as true. That has two consequences, and both are silent:

  • It inverts any literal boolean string. Boolean('false') is true, and Boolean('off') is true. A field carrying the literal text "false" or "off" coerces to true. Checkboxes never send those, since they send "on" or nothing, so this consequence doesn’t bite the checkbox directly. But the instant you have a <select> or a hidden field whose value is the word "true"/"false" or "on"/"off", z.coerce.boolean() flips half your data the wrong way.
  • It can never reject a bad value. Boolean(anything) always returns a real boolean, so the inner z.boolean() always sees a valid boolean and the parse always succeeds. 'on', 'false', 'off', '', even the absent undefined all sail through: undefined becomes false, and every non-empty string becomes true. There is no input z.coerce.boolean() rejects, so it can’t catch a malformed value, and it silently treats the absent-when-unchecked case as the same thing as a deliberate false.

So the obvious tool inverts any "false"/"off" it ever sees and can never reject garbage. The correct shape is a z.preprocess, a transform that maps the wire value to a real boolean before z.boolean() validates it:

archived: z.preprocess((value) => value === 'on' || value === true, z.boolean()),

Walk the pieces. value === 'on' catches the checked checkbox. || value === true catches a programmatic or JSON caller that already sent a real boolean. Anything else, including the absent undefined, an empty string, or a stray "false", falls through to false. The result is always a genuine boolean, which the inner z.boolean() then validates. The unchecked checkbox becomes false, exactly as a user expects, and the schema means exactly 'on' rather than “anything truthy.”

One more boolean tool is worth knowing so you don’t reach for the wrong one. Zod 4 ships z.stringbool(), built for strings that spell a boolean: it accepts "true"/"false", "yes"/"no", "on"/"off", "1"/"0", case-insensitively, and you can customize the word lists with { truthy, falsy }. It’s the right choice for a <select> or text field whose value is literally the word “true.” But it expects a present string, so it doesn’t model the absent-when-unchecked checkbox. The line is clean: z.stringbool() for a string that spells a boolean, z.preprocess for a checkbox that’s present or absent. Use z.coerce.boolean() for neither, when the source is form data.

Let the question pick the tool rather than reaching by reflex. Walk this decision:

Which boolean shape is this form field?

This trap is best watched rather than graded. Because z.coerce.boolean() accepts every value, no fixture can go red to flag it; the damage is in the produced value, not in a pass or fail. Open the playground below with the naive z.coerce.boolean() schema and feed it 'on', 'false', 'off', and '' in turn. Watch the output pane: 'false' and 'off' both come out true, the exact opposite of what the words say. Then rewrite archived to the z.preprocess form in the commented hint and watch the output match what the wire value actually means.

The empty-string trap: optional numbers that become zero

Section titled “The empty-string trap: optional numbers that become zero”

The second trap has the same shape as the first, a successful parse that produces the wrong value, but on a field you might not think to check. It never throws and never turns a row red. It just writes a wrong number to your database and moves on.

Here’s the problem. Number('') is 0: not NaN, not an error, but a clean, plausible zero. An empty text or number input submits the empty string '', where the key is present and the value is blank. So z.coerce.number() on a blank field runs Number(''), gets 0, and that 0 sails through any .nonnegative() or .min(0) you put after it. Picture a user who leaves an optional “discount” field empty. They meant “no discount,” but the schema records a discount of zero, a real, applied, zero-percent discount. The parse succeeds, and nothing anywhere signals that a blank just became a number.

The boundary is worth drawing, because z.coerce.number() isn’t wrong everywhere. On a required field with a sensible floor, such as a price that must be .positive() or a quantity that’s .min(1), a blank submission should fail, and it does: Number('') is 0, and 0 fails .positive(), so the user gets the validation error they should. The trap is narrower than that. It bites optional numerics, where “blank” is a legitimate answer meaning “absent” and the schema quietly translates it to “zero” instead.

The fix is to decide, in the schema, that a blank means absent, by mapping the empty string to undefined before coercion runs.

const schema = z.object({
discount: z.coerce.number().nonnegative().optional(),
});
// { discount: '' } → { discount: 0 } ← blank became a real zero

.optional() looks like it handles the blank case, but the blank isn’t undefined, it’s the string ''. z.coerce.number() runs Number('') and gets 0, which passes .nonnegative(), so .optional() never fires. A user who entered nothing now has a zero discount, and since the parse succeeded, nothing warned you.

There’s a second shape for the same intent, heavier but more explicit about what it does: a union that names the empty string and transforms it away.

discount: z.union([
z.literal('').transform(() => undefined),
z.coerce.number().nonnegative(),
]).optional(),

Reach for the union form when you want “the empty string means nothing” to read as a deliberate rule in the schema rather than a preprocessing step, and reach for the z.preprocess form for the common case. Either way, the principle is the same: the schema is where “blank means absent” gets decided, once. Encode it there and every consumer of the parsed value sees number | undefined and cannot treat a blank as a zero. The wrong interpretation never escapes the boundary.

This trap is best read rather than graded, because the naive schema and the fixed one both parse a blank successfully and only the produced value differs. Open the playground below with the naive schema and a blank discount, and look at the output: a 0 where you expected undefined. Then edit the schema to the z.preprocess form and watch the output flip.

The date trap: z.coerce.date() is too loose about format

Section titled “The date trap: z.coerce.date() is too loose about format”

The third trap isn’t that z.coerce.date() lets garbage through. Feed it "not-a-date" and it rejects cleanly. The trap is the opposite: it’s too lenient about what counts as a date, so it accepts a string your contract should refuse.

z.coerce.date() runs new Date(input), and new Date happily parses far more than a full ISO 8601 timestamp. A bare date with no time, "2026-01-15", parses straight to a Date at midnight UTC, and that’s the problem. If your contract is “an invoice’s issuedAt is a precise instant” but the form sends a date-only "2026-01-15", z.coerce.date() says yes and silently invents a 00:00:00Z time you never agreed to. The schema accepted a shape it should have named and rejected. z.coerce.date() validates that the input is parseable as some date, not that it’s the date format your contract specifies, and on a boundary the format is part of the contract.

The tool that names the format is z.iso.datetime(): it requires a full ISO 8601 timestamp with a time component and a Z, and it rejects a date-only string outright. Both reject true garbage like "not-a-date"; the difference is the date-only case, where z.coerce.date() is lenient and z.iso.datetime() holds the line.

See the looseness directly. The exercise below is inverted on purpose: the starter uses bare z.coerce.date(), so the date-only fixture, which the contract should refuse, wrongly passes (stays green when it should be red). You fix a false pass by switching to the format that names the contract precisely.

Backwards on purpose. The `date-only (too loose)` row should fail — the contract wants a precise timestamp — but with the starter `z.coerce.date()` it *passes*, because new Date('2026-01-15') is a valid Date (midnight UTC). The `garbage` row already fails under both. Switch `issuedAt` to z.iso.datetime() so the date-only string is rejected for missing its time component, then watch the `date-only` row flip to a correct fail.

Booting type-checker…
Test scenario Value
full ISO datetime {"issuedAt":"2026-01-15T10:30:00Z"}
date-only (too loose) — should fail {"issuedAt":"2026-01-15"}
garbage {"issuedAt":"not-a-date"}

So z.iso.datetime() is how you name the format. But you have a second choice to make, whether the action wants the date as a string or a Date, and which one you pick depends entirely on where the date goes next. That’s the rule this whole section comes down to: pick by where the date is used.

issuedAt: z.iso.datetime(),
// output type: string

Reach for this when the date stays text all the way to the database. z.iso.datetime() validates the string format, a full ISO 8601 timestamp with a time component and a Z, and infers as string. The action receives a string, and if the next stop is a timestamptz column, Drizzle converts it on write. No JS Date is needed.

To put the two choices side by side: if the date stays text all the way to the database, use z.iso.datetime(), which validates the format, infers as string, and lets the action pass a string straight to a timestamptz column. If JavaScript code needs a real Date to do math or formatting, use z.iso.datetime().transform(s => new Date(s)), which validates the strict format first and then constructs the Date, so a date-only or otherwise off-contract string is rejected before any Date is built. Either way you’ve named the exact format the contract requires. Bare z.coerce.date() is the one to avoid on a boundary: it accepts whatever new Date can parse, which is looser than the contract, and you find out only when a half-specified date has already become a row.

The last seam is the one non-string value off a form. A file input on a multipart/form-data form makes formData.get('avatar') return a File instance, the only place the wire carries something other than text.

The validator at this seam is z.instanceof(File), and you constrain it the same way you’d constrain anything else, with refinements. The two checks that matter at the boundary are size and type.

const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
const avatarSchema = z
.instanceof(File)
.refine((file) => file.size > 0, { error: 'No file uploaded' })
.refine((file) => file.size <= MAX_BYTES, { error: 'File too large' })
.refine((file) => ALLOWED_TYPES.includes(file.type), {
error: 'Unsupported file type',
});

file.size is the byte count, and file.type is the file’s MIME type , a string like image/png. The file.size > 0 refine earns its place because an empty file input yields a zero-byte File in some browsers rather than nothing at all, so “no file” and “a file” both arrive as a File, and the size check is how you tell them apart. For a genuinely optional file field, add .optional() and let that guard handle the empty case.

Two things keep this in scope. First, File is a Web API global, not a browser-only one. Next.js runs Server Actions and route handlers on a runtime that provides File, so this validation works server-side without a polyfill. Second, and more important, this validates the file’s shape at the boundary and nothing more. The real upload story, with presigned URLs, object storage, and streaming the bytes somewhere durable, is a whole chapter later in the course. Here, a file is just one more field the schema checks before the rest of the action runs. Validate that it’s a file, that it’s not too big, and that it’s a type you allow, then move on.

Pull the patterns into the schema you’ll actually carry forward. This is invoiceFormSchema with the traps designed out: the coerced number, the strict date, the preprocess boolean for the checkbox, and the optional notes.

const invoiceFormSchema = z.object({
customerId: z.uuid(),
total: z.coerce.number().positive().multipleOf(0.01),
issuedAt: z.iso.datetime().transform((value) => new Date(value)),
archived: z.preprocess(
(value) => value === 'on' || value === true,
z.boolean(),
),
notes: z.string().optional(),
});
type InvoiceFormInput = z.input<typeof invoiceFormSchema>;

customerId is already a string off the wire, so it’s validated, not coerced.

const invoiceFormSchema = z.object({
customerId: z.uuid(),
total: z.coerce.number().positive().multipleOf(0.01),
issuedAt: z.iso.datetime().transform((value) => new Date(value)),
archived: z.preprocess(
(value) => value === 'on' || value === true,
z.boolean(),
),
notes: z.string().optional(),
});
type InvoiceFormInput = z.input<typeof invoiceFormSchema>;

total uses z.coerce.number() to bridge the string to a number, and the checks run on the coerced value.

const invoiceFormSchema = z.object({
customerId: z.uuid(),
total: z.coerce.number().positive().multipleOf(0.01),
issuedAt: z.iso.datetime().transform((value) => new Date(value)),
archived: z.preprocess(
(value) => value === 'on' || value === true,
z.boolean(),
),
notes: z.string().optional(),
});
type InvoiceFormInput = z.input<typeof invoiceFormSchema>;

issuedAt runs strict ISO validation and then transforms to Date. This avoids the date trap: it names the exact format the contract requires, a full timestamp, so a too-loose date-only string is rejected before any Date is built. z.coerce.date() would have accepted that string.

const invoiceFormSchema = z.object({
customerId: z.uuid(),
total: z.coerce.number().positive().multipleOf(0.01),
issuedAt: z.iso.datetime().transform((value) => new Date(value)),
archived: z.preprocess(
(value) => value === 'on' || value === true,
z.boolean(),
),
notes: z.string().optional(),
});
type InvoiceFormInput = z.input<typeof invoiceFormSchema>;

archived is the checkbox shape. z.preprocess maps 'on'/true to true and the absent undefined to false. Don’t use z.coerce.boolean() here: it tests for truthiness, so it accepts everything and would flip a literal "false"/"off" to true.

const invoiceFormSchema = z.object({
customerId: z.uuid(),
total: z.coerce.number().positive().multipleOf(0.01),
issuedAt: z.iso.datetime().transform((value) => new Date(value)),
archived: z.preprocess(
(value) => value === 'on' || value === true,
z.boolean(),
),
notes: z.string().optional(),
});
type InvoiceFormInput = z.input<typeof invoiceFormSchema>;

InvoiceFormInput is the form-side z.input type: strings where the output has a number and a Date.

1 / 1

That schema is the contract. Wire a <form> whose name attributes match its keys, open your action with invoiceFormSchema.safeParse(Object.fromEntries(formData)), and the bridge is built. Keep these patterns close, since you’ll apply them to every form schema you write:

  • A number off a form uses z.coerce.number(), but an optional number maps empty to undefined first, or you’ll write a silent zero.
  • A boolean checkbox uses z.preprocess(v => v === 'on' || v === true, z.boolean()), never z.coerce.boolean().
  • A date uses z.iso.datetime() if it stays text, or the strict .transform(s => new Date(s)) if you need a Date, never bare z.coerce.date().
  • An array field uses formData.getAll(name), not Object.fromEntries.
  • A file uses z.instanceof(File) with size and type refinements.

Apply those patterns now, on five fields you haven’t seen as a set. Sort each into the Zod shape it needs.

Each chip describes a field on a form. Drag it into the Zod shape that field needs at the FormData boundary. Drag each item into the bucket it belongs to, then press Check.

z.coerce.number() Numeric, required, has a floor
z.preprocess(… 'on' …) A checkbox: present or absent
z.iso.datetime().transform(…) ISO string you need as a Date in JS
z.instanceof(File) An uploaded file, size/type-checked
A required price that must be positive
A required quantity with .min(1)
An archived checkbox, checked or not
A dueDate used for a JavaScript countdown timer
A CSV upload field, max 5 MB

You now have the bridge. The schema coerces and validates the whole form in one safeParse, and you know the four places where JavaScript’s defaults disagree with HTML’s wire format (checkboxes, optional numbers, dates, and repeated keys) and how to design each one out. What you’ve built stops at the parse. Next chapter picks up the parsed.success branch: the Server Action that calls this schema, the Result it returns, and the field errors it sends back to the form.

The contract is written; the next step is wiring it in.