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:
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:
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:
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.
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.
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:
const result = teacherSchema.safeParse({ name: 'Jonathan', age: 21 })
console.log(result)
// { success: true, data: { name: 'Jonathan', age: 21 } }When it fails, the shape changes:
const result = teacherSchema.safeParse({ name: 'Jonathan', age: '21' })
console.log(result.success)
// falseA 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:
if (result.success) {
console.log('Safe to send onward:', result.data)
} else {
console.log('Show the user what went wrong:', result.error.issues)
}parse throws, which stops everything unless you catch it. safeParse returns an object with a success flag, so you can ask and carry on.
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:
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:
const contactSchema = z.object({
email: z.email(),
})
contactSchema.parse({ email: '[email protected]' }) // fine
contactSchema.parse({ email: 'jabbahuttcorp.com' }) // ZodErrorThat's what the library means by declarative: you describe the result you want, and it works out the checking.
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:
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.
So z.coerce.number().min(18) converts the text to a number and then asks whether that number is at least eighteen.
Try it
Starting from this schema and object:
const characterSchema = z.object({
name: z.string(),
episode: z.number(),
})
const character = {
name: 'Jabba the Hutt',
episode: '6',
}Make four changes:
- Create a
Charactertype inferred from the schema, and annotate the object with it. - Add an optional
isJediboolean key to the schema. - Make
episodeaccept the string'6'and store it as a number. - Log whether validation passed or failed, as a single boolean.
Compare your answers
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)
// trueFour notes on the four changes:
z.infer<typeof characterSchema>reads the type off the schema, so addingisJediupdates it with no second edit..optional()means the key can be absent, which is whycharacterneeds noisJediand still passes.z.coerce.number()converts before checking, so the string'6'becomes the number6..successis the boolean. LoggingsafeParse(...)alone prints the whole result object, andparsewould 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.

