Skip to content
Chapter 42Lesson 6

Coercing FormData strings with Zod

How Zod's coercion and preprocessing turn a browser form's string-only values into typed, validated data.

A user fills in a form and hits submit. On the server, formData.get('quantity') returns the string "3", not the number 3. formData.get('archived') returns "on", or nothing at all, never a boolean. formData.get('issuedAt') returns an ISO string like "2026-01-15T10:30:00Z", not a Date. But your server code wants a number, a boolean, a Date: the typed domain you spent the last five lessons describing with Zod. The wire between browser and server carries none of those types. It carries strings.

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

When a browser submits a <form>, it serializes every control to text. The server reconstructs a FormData object that mirrors the form, and every value you read from 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"

The platform types every value as FormDataEntryValue, which is string | File. No number, no boolean, no Date, no array crosses the wire. This isn’t a Zod limitation, it’s the HTML form specification: a <form> can carry only text, plus one other thing, files.

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 shapes get their own sections later. A field can appear more than once, from a multi-select or several checkboxes that share a name; formData.getAll(name) returns every value as a string[]. 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. Until then, assume everything 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 unpacks 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' }

Every value in raw is still a string, which is exactly what you hand to safeParse: one call collects the form, and the schema validates and coerces it in a single pass.

Object.fromEntries has one sharp edge, the first of this lesson’s four traps. When a key appears more than once, it keeps only the last value and silently drops the rest. The trap comes from fromEntries, not from Zod, which is why it bites before the schema runs:

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

tags was checked twice, and fromEntries collapsed both values to the last one. The schema’s z.array(z.string()) receives the string 'paid' instead of an array and fails with a confusing “expected array, received string.” The error points at the schema, but the bug is two lines up.

Nothing detects multi-valued fields for you, so you have to know which they are. Name those inputs with a plural like tags, and treat any field whose schema is a z.array(...) as one that needs a getAll.

z.coerce: a transform that runs a constructor

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

You have a raw object full of strings, and the schema wants numbers and dates. The tool is z.coerce, the form-input shortcut Checks and transforms flagged for this very moment, built on the transform machinery that lesson taught.

A transform runs a function on the input before the inner schema validates, and it changes the inferred output type. z.coerce is that, with the function fixed to a JavaScript constructor. z.coerce.number() is z.number() with a built-in transform that calls Number(input) first. The pattern repeats:

  • 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. So any .positive() or .min() you chain validates the coerced value, not the original string.

This is the input/output type split from Derive schema variants. 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 genuinely differ. The form contract, 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>, the same type z.infer gives you.

Here is the schema this unit is built on, 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, so there is nothing to coerce: z.uuid() only validates the format. Coerce only the fields 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 is Date. This line hides a trap you’ll fix later, where it proves 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 may send, where total and issuedAt are still strings. It differs from z.infer, the output, where they are a number and a Date. When you wire this to a <form>, the input type is the contract.

1 / 1

Every form-consuming function in this unit opens with one line that combines both moves:

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. Afterward, parsed is either a success holding a fully typed invoice or a failure holding the issues. Handling that result, rendering errors, and writing to the database come next chapter, where you wire this into a Server Action. For now, every snippet stops at the safeParse: 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 is the happy path, in 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 a form, and the obvious schema for it, z.coerce.boolean(), is wrong in a way worse than throwing: it never fails.

The wire shape is where the danger starts. A checked checkbox sends name=on; an unchecked checkbox sends nothing at all, so after Object.fromEntries the field is undefined, not "off", "false", or "". A checkbox is present or absent, never present with a false value.

z.coerce.boolean() transforms with Boolean(input), and Boolean treats every non-empty string as true. Two silent consequences follow:

  • It inverts any literal boolean string. Boolean('false') and Boolean('off') are both true. Checkboxes never send those words, so this misses the checkbox directly, but a <select> or hidden field carrying "false" or "off" flips to true.
  • It can never reject a bad value. Boolean(anything) returns a real boolean, so the inner z.boolean() always passes. 'on', 'false', 'off', '', and the absent undefined all sail through, and undefined becomes the same false as a deliberate one.

The correct shape is a z.preprocess that maps the wire value to a real boolean before z.boolean() validates it:

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

value === 'on' catches the checked checkbox; || value === true catches a JSON caller that already sent a real boolean; everything else, including the absent undefined, falls through to false. The unchecked checkbox becomes false, and the schema means exactly 'on' rather than “anything truthy.”

Zod 4 also ships z.stringbool() for strings that spell a boolean: it accepts "true"/"false", "yes"/"no", "on"/"off", "1"/"0" case-insensitively, with custom word lists via { truthy, falsy }. It expects a present string, so it fits a <select> or text field but not 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, z.coerce.boolean() for neither.

Let the question pick the tool:

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, and the damage is in the produced value. Open the playground with the naive schema and feed it 'on', 'false', 'off', and ''; 'false' and 'off' both come out true, the opposite of what the words say. Then rewrite archived to the z.preprocess form and watch the output match the wire value’s meaning.

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 shape of the first, a successful parse that produces the wrong value, but on a field you might not think to check. Number('') is 0: not NaN, not an error, but a clean, plausible zero. An empty input submits the string '', so z.coerce.number() on a blank field runs Number(''), gets 0, and that 0 sails through any .nonnegative() or .min(0) after it. A user who leaves an optional “discount” field empty meant “no discount,” but the schema records a real, applied, zero-percent discount. The parse succeeds, and nothing signals that a blank just became a number.

z.coerce.number() isn’t wrong everywhere. On a required field with a sensible floor, a price that must be .positive() or a quantity that’s .min(1), a blank submission should fail, and it does: 0 fails .positive(), so the user gets the validation error they should. The trap bites only optional numerics, where “blank” legitimately means “absent” and the schema quietly translates it to “zero.”

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, but the blank isn’t undefined, it’s the string ''. z.coerce.number() turns it into 0, which passes .nonnegative(), so .optional() never fires. A user who entered nothing now has a zero discount, and the parse succeeded, so nothing warned you.

A union that names the empty string and transforms it away expresses the same intent more explicitly:

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

Reach for the union when you want “the empty string means nothing” to read as a deliberate rule rather than a preprocessing step, and for z.preprocess in the common case. Either way the schema decides “blank means absent” once, every consumer sees number | undefined, and the wrong interpretation never escapes the boundary.

This trap is best read rather than graded, because the naive and fixed schemas both parse a blank successfully and only the produced value differs. Open the playground with the naive schema and a blank discount: the output shows 0 where you expected undefined. Edit it 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.

z.coerce.date() runs new Date(input), which 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. 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. It validates that the input is parseable as some date, not that it’s the format your contract specifies, and on a boundary the format is part of the contract.

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

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"}

You have a second choice once the format is named: whether the action wants the date as a string or a Date. Pick by where the date is used next.

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.

The last boundary is the one non-string value off a form. On a multipart/form-data form, a file input makes formData.get('avatar') return a File instance instead of text.

The validator is z.instanceof(File), constrained with refinements like anything else. The two checks that matter 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 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, 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, add .optional() and let that guard handle the empty case.

Two things keep this in scope. First, File is a Web API global, not browser-only: Next.js provides it in Server Actions and route handlers, so this runs server-side with no polyfill. Second, this validates the file’s shape and nothing more. The real upload pipeline (presigned URLs, object storage, streaming the bytes somewhere durable) is a later chapter. Here a file is just one more field: check that it’s a file, not too big, and a type you allow, then move on.

Pull the patterns into the schema you’ll 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.

Now apply those patterns to five fields you haven’t seen as a set. Sort each into the Zod shape it needs.

Each chip describes a form field. 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: one safeParse coerces and validates the whole form, and you know the four places where JavaScript’s defaults disagree with HTML’s wire format (checkboxes, optional numbers, dates, repeated keys) and how to design each one out. This 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.