Skip to content

Inferring types and coercing input

A schema that accepts or throws is enough to describe data. Building with it needs two more things: a way to use the shape in your own code, and a way to handle a failure without an exception.

Start from a schema describing a teacher:

js
import * as z from 'zod'

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

One schema, two jobs

In TypeScript you'd normally write the shape a second time:

ts
type Teacher = {
  name: string
  age: number
}

Now the same shape lives in two places, and they drift the first time somebody adds a field to one of them.

z.infer takes the type straight off the schema:

ts
type Teacher = z.infer<typeof teacherSchema>
// { name: string; age: number }

Add a key to the schema and the type follows on its own. The schema is the definition, and TypeScript reads it.

JunoOne schema, two jobs The typeof in there looks odd because it isn't the JavaScript typeof you know. This is TypeScript's version, which asks "what is the type of this value" at the type level.

You can treat the whole line as one phrase: give me the type this schema describes.

JunoOne schema, two jobs The practical win is that the type cannot go stale. Add email to the schema and every function taking a Teacher starts requiring it, so the compiler shows you each place that needs updating.

Write the type by hand instead and it silently keeps describing the old shape, which is worse than no type: the code claims a guarantee it no longer has.

JunoOne schema, two jobs Input and output types can differ, which matters once defaults and coercion appear. A schema with .default() accepts an object without that key and returns one with it, so what you may pass in and what you get back are two different shapes.

z.infer gives you the output type, which is the one you want almost everywhere, because you should be reading the parsed value rather than the raw input. When you do need the other side, for typing what a caller may send, that's z.input. Reaching for it is usually a sign the raw value is being used somewhere it shouldn't be.

Failure without an exception

parse throws. That suits a boundary where a bad request should stop everything, and suits nothing where you want to look at what went wrong.

safeParse hands back a result instead:

js
const result = teacherSchema.safeParse({ name: 'Jonathan', age: 21 })

console.log(result)
// { success: true, data: { name: 'Jonathan', age: 21 } }

When it fails, the shape changes:

js
const result = teacherSchema.safeParse({ name: 'Jonathan', age: '21' })

console.log(result.success)
// false

A success gives you success: true and data. A failure gives you success: false and error. So a form can ask the question and decide what to do:

js
if (result.success) {
  console.log('Safe to send onward:', result.data)
} else {
  console.log('Show the user what went wrong:', result.error.issues)
}
JunoFailure without an exception Both versions do the same checking. The difference is what they hand you when the answer is no.

parse throws, which stops everything unless you catch it. safeParse returns an object with a success flag, so you can ask and carry on.

JunoFailure without an exception Pick by what should happen next. In a route handler where a bad body means a 400 response and nothing else, parse inside your error middleware is clean. In a form where a failure means showing three messages next to three fields, safeParse is the one.

One habit worth forming: read result.data, never the original object. They're the same today, and they stop being the same the moment a default or a coercion enters the schema.

JunoFailure without an exception The result is a discriminated union, so TypeScript narrows it for you. Inside an if (result.success) branch, result.data is typed and result.error doesn't exist; in the else branch it's the reverse. Checking success first isn't a style preference, it's how you get access to either field.

Worth knowing that parse and safeParse do identical work. safeParse is not a lenient mode, it catches the same failures and reports them differently. There's also no meaningful performance difference to choose between them, so decide on control flow alone.

Narrowing what you accept

A type is a coarse filter. z.number() accepts -4 and 9e99 alike, and a teacher is neither age.

Methods chain onto a schema to tighten it:

js
const teacherSchema = z.object({
  name: z.string(),
  age: z.number().min(18),
  isAmerican: z.boolean().optional(),
  id: z.number().default(() => Math.random()),
})
  • .min(18) rejects anything below eighteen. .gte() and .lte() do the same job with explicit comparisons.
  • .optional() lets the key be missing entirely.
  • .default() supplies a value when the key is absent, so the parsed object always has one.

Zod also ships checks for common formats, so an email is one call:

js
const contactSchema = z.object({
  email: z.email(),
})

contactSchema.parse({ email: '[email protected]' })  // fine
contactSchema.parse({ email: 'jabbahuttcorp.com' })   // ZodError
JunoNarrowing what you accept Each of these reads as a sentence if you say it out loud. A number, at least eighteen. A boolean, optional. A number, defaulting to something.

That's what the library means by declarative: you describe the result you want, and it works out the checking.

JunoNarrowing what you accept Maximums are the ones people forget, and they're the ones that matter for the abuse this section opened with. A field with a minimum and no maximum still accepts ten million characters.

Put a .max() on every string you store. It's the cheapest possible answer to the oversized-input problem, and it belongs in the schema rather than scattered through handlers.

JunoNarrowing what you accept.default() has a trap that the first example anyone writes walks straight into. Writing .default(Math.random()) calls the function once, when the schema is built, so every parse for the lifetime of the process receives the identical value. Verified on Zod 4.5.4: two parses of an empty object return the same number.

Pass a function instead, .default(() => Math.random()), and it's evaluated per parse. Two parses, two numbers. The same applies to Date.now() and to any generated id, and the failure is quiet, because a default that never changes still looks like a default.

On z.email(): it's a format check, and no regular expression decides whether an address can receive mail. Treat it as a way to reject the obviously malformed, and treat a confirmation link as the thing that establishes the address is real.

Coercing what arrives

HTML form fields send strings. Every one of them, including the field labelled "age" with a number spinner beside it.

So a schema expecting z.number() rejects perfectly good form input, because '13' is a string. Coercion converts first, then validates:

js
const characterSchema = z.object({
  name: z.string(),
  episode: z.coerce.number(),
})

characterSchema.parse({ name: 'Luke Skywalker', episode: '4' })
// { name: 'Luke Skywalker', episode: 4 }

The string went in, a number came out, and any further checks on that key ran against the number.

JunoCoercing what arrives The order matters here, and it's the reverse of what you might guess. Coercion happens first, then the checking runs on the result.

So z.coerce.number().min(18) converts the text to a number and then asks whether that number is at least eighteen.

JunoCoercing what arrives Reach for coercion at the edges where the transport loses type information: form bodies, query strings, environment variables, CSV rows. Everything arrives as text there and something has to convert it.

Between your own services, where JSON already carries real numbers and booleans, coercion mostly hides bugs. If a service is sending you "42" where the contract says a number, you want to know.

JunoCoercing what arrives Coercion uses JavaScript's own conversion rules, which are looser than the word suggests. Two results verified on Zod 4.5.4 and both worth remembering.

z.coerce.number() accepts an empty string and returns 0, successfully. An untouched numeric field in a form submits "", so a required amount silently becomes zero rather than failing validation. Pair coercion with a range check, or reject empty strings before parsing.

z.coerce.boolean() is worse: it applies Boolean(), so the string "false" becomes true, as does "0" and anything else non-empty. It's almost never what you want for a checkbox or a query parameter. Match the two strings you actually expect and map them yourself.

Try it

Starting from this schema and object:

js
const characterSchema = z.object({
  name: z.string(),
  episode: z.number(),
})

const character = {
  name: 'Jabba the Hutt',
  episode: '6',
}

Make four changes:

  1. Create a Character type inferred from the schema, and annotate the object with it.
  2. Add an optional isJedi boolean key to the schema.
  3. Make episode accept the string '6' and store it as a number.
  4. Log whether validation passed or failed, as a single boolean.
Compare your answers
ts
type Character = z.infer<typeof characterSchema>

const characterSchema = z.object({
  name: z.string(),
  episode: z.coerce.number(),
  isJedi: z.boolean().optional(),
})

const character: Character = {
  name: 'Jabba the Hutt',
  episode: '6',
}

console.log(characterSchema.safeParse(character).success)
// true

Four notes on the four changes:

  • z.infer<typeof characterSchema> reads the type off the schema, so adding isJedi updates it with no second edit.
  • .optional() means the key can be absent, which is why character needs no isJedi and still passes.
  • z.coerce.number() converts before checking, so the string '6' becomes the number 6.
  • .success is the boolean. Logging safeParse(...) alone prints the whole result object, and parse would give you the data or an exception, neither of which is a boolean.

One catch worth noticing: with episode coerced, the annotated type says number while the object literal holds '6'. That's the input and output shapes differing, and it's why reading result.data rather than the original object matters.

Where this goes next

A failed safeParse hands back an error, and so far that's been a thing to check the existence of rather than a thing to read.

Reading validation errors opens it up: which field failed, what was wrong with it, and how to turn that into messages a person can act on.