Schema validation with Zod
Describe a form's data once with a Zod 4 schema, validate with safeParse(), show the issues per field, get the TypeScript types for free, and run the same schema on the server.
What you'll learn
When you finish, you can do these.- 1Write a form's rules in a Zod schema and get its types
- 2Check a form with safeParse() and show each field's error
- 3Make sure two fields match and reuse the schema on a server
- 4Spot and fix an optional number that fails when left blank
A sign-up form asks for a name, an email, a password and an optional age, and each field has a rule: a name needs at least 2 characters, an age must be 13 or more. The server, which receives the form, needs a second copy of every rule, because anyone can send it data without the form. One day the two copies disagree.
A schema describes the data once: which fields exist, what each must look like, and what to say when it does not. Zod, the most downloaded schema library for JavaScript, checks data against it in the browser and on the server, and gives TypeScript the types. The Code tab holds the sign-up form: App.vue is the page, Field.vue draws one input with its label and error, and schema.ts holds the schema. By the end you can add rules of your own to it. You need one idea from Form state and validation by hand: a field shows its error only once the person has left it.
Describe the data once
The schema in schema.ts holds every rule of the sign-up form, in a file of its own so that a server can import it too. z.object({ ... }) lists the fields, and each field gets a type, its rules, and the message to show when a rule fails.
import { z } from 'zod'
export const signupSchema = z.object({
name: z.string().trim().min(2, 'Enter at least 2 characters'),
email: z.email('Enter a valid email'),
password: z.string().min(8, 'At least 8 characters'),
age: z.preprocess(v => (v === '' ? undefined : v), z.coerce.number().min(13, 'You must be 13 or older').optional()),
newsletter: z.boolean(),
})
z.string().trim() is more than a check: it changes the value, so the name comes out with no spaces around it. The section on coercion explains the long age line.
The schema also gives you two TypeScript types. z.infer is the type of what comes out of parsing, Zod's word for checking and cleaning: in it, age is number | undefined. z.input is the type of what may go in: in it, age is unknown, because z.preprocess() accepts anything. schema.ts exports both, named Signup and SignupInput:
export type Signup = z.infer<typeof signupSchema>
export type SignupInput = z.input<typeof signupSchema>
App.vue types its result with Signup, and when a rule changes, the types follow. Parsing sits in the middle of the data's trip:
- Form valuesstrings and ''
- safeParsetrim, coerce, check
- Typed data
- or issues per field
- Serversame schema
Validate with safeParse and show the issues per field
App.vue checks the form in one function, validate(). In it, signupSchema.safeParse(form) returns { success: true, data } or { success: false, error }. It does not throw on wrong input, so a bad email never stops your code.
error.issues lists each problem with a path such as ['email'] and a message. z.flattenError(error).fieldErrors groups them by field into fieldErrors: { email: ['Enter a valid email'] }, an array per field, because one field can fail two rules.
function validate() {
const parsed = signupSchema.safeParse(form)
fieldErrors.value = parsed.success ? {} : z.flattenError(parsed.error).fieldErrors
return parsed
}
watch(form, validate)
const message = (key: Key) => (touched[key] ? fieldErrors.value[key]?.[0] ?? '' : '')
watch(form, validate) runs the check on every change, so a message disappears the moment the field is fixed. message() shows a field's first message once touched records that the person has left the field.
Pressing Sign up runs submit(): it marks every field touched, validates, and on failure moves the focus to the first field with an error, which Field.vue marks aria-invalid.
async function submit() {
for (const key of Object.keys(form) as Key[]) touched[key] = true
const parsed = validate()
if (!parsed.success) {
await nextTick()
formEl.value?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()
return
}
result.value = parsed.data
}
On success, use parsed.data, not form: it is the cleaned version, with the name trimmed and the age a number, and it has the Signup type. The starter prints it under the form: "age": 15 without quotes after typing 15, and no age at all after leaving Age empty.
Coercion, rules across fields, async checks and the server
Four more tools finish the sign-up form.
Inputs hand you strings, and coercion turns them into the type you want: z.coerce.number() runs Number() first, so '15' becomes 15. A cleared number field gives '', the empty string, so the age line wraps the rule in z.preprocess(), which runs your own function first: it turns '' into undefined, and .optional() accepts that.
A rule that reads two fields goes on the object with .refine(), after the closing }) of z.object(). Its path names the field that shows the message; without it the message lands in formErrors, attached to no field. With a confirm field in the schema, this rule compares it with the password:
.refine(data => data.password === data.confirm, { message: 'Passwords do not match', path: ['confirm'] })
Try it 1 adds the field: two different passwords then show Passwords do not match under Confirm password.
Asking the server whether an email is already taken needs an async .refine(), one whose answer arrives later. A schema with one async rule must be parsed with safeParseAsync(). On such a schema even safeParse() throws: Encountered Promise during synchronous parse. Use .parseAsync() instead.
The same file protects the server. The Code tab has none, but in a Nuxt project, a Vue app with its own server, the file would sit in shared/utils/, say as signup.ts, where pages and server both import it.
A server route, a file that answers one URL, reads the request body, the data sent to it, with readValidatedBody(). It hands the body to signupSchema.parse, which, unlike safeParse(), throws on bad data.
import { signupSchema } from '~~/shared/utils/signup'
export default defineEventHandler(async (event) => {
const data = await readValidatedBody(event, signupSchema.parse)
return createAccount(data)
})
A body the form would refuse gets status 400, an error answer, before createAccount(), your own code that saves the account, runs.
What goes wrong
The first mistake is coercing an empty optional field. z.coerce.number() looks like the obvious way to read a number input.
age: z.coerce.number().min(13, 'You must be 13 or older').optional(),
Leave Age empty and submit: You must be 13 or older appears under a field marked optional, because Number('') is 0, which is under 13, and .optional() lets only undefined through. Turn the blank into undefined before coercion runs.
age: z.preprocess(v => (v === '' ? undefined : v), z.coerce.number().min(13, 'You must be 13 or older').optional()),
Now a blank Age passes, and the printed result has no age.
The second mistake is mapping issues by hand. One line of Object.fromEntries(), which builds an object from pairs, looks like enough to turn the issues of a failed parse into messages.
fieldErrors.value = Object.fromEntries(parsed.error.issues.map(i => [i.path[0], i.message]))
Say Name has a second rule, .regex(/^[^0-9]*$/), which refuses digits. A name typed as 1 fails both rules, yet the object keeps one message for name: the second entry overwrote the first.
fieldErrors.value = z.flattenError(parsed.error).fieldErrors
z.flattenError() keeps every message, in an array per field.
What to remember
- A schema holds the fields, rules and messages in one place;
z.inferandz.inputgive its output and input types. safeParse()does not throw on bad data: it returns typed data or a list of issues, andz.flattenError(error).fieldErrorsgives an array of messages per field. Useparsed.data, never the raw form object..refine()with apathputs a cross-field message on the right field, and an async rule needssafeParseAsync().- Import the same schema in the server route;
readValidatedBody()answers 400 for anything the form would refuse. z.coerceturns''into 0; wrap it inz.preprocess()when a blank must mean "not given".
Try it
Open the Code tab and change the starter code. Each task says what you should see.- Add
confirm: z.string(),to the schema and.refine(data => data.password === data.confirm, { message: 'Passwords do not match', path: ['confirm'] })after the closing}). Addconfirm: ''toformand a<Field v-model="form.confirm" label="Confirm password" type="password" :error="message('confirm')" @blur="touched.confirm = true" />under Password. Type two different passwords and leave the field: Passwords do not match appears under Confirm password. - Append
.refine(async email => email !== 'taken@example.com', 'That email already has an account')to theemailline. Type one letter anywhere: the preview shows Encountered Promise during synchronous parse. Makevalidateanasync functionthat usesawait signupSchema.safeParseAsync(form), and writeawait validate()insubmit. Submit withtaken@example.com: the message appears under Email. - Replace the
ageline withage: z.coerce.number().min(13, 'You must be 13 or older').optional(),, fill in the other fields, leave Age empty and submit: You must be 13 or older appears under an optional field, and focus lands in it. Put thez.preprocessline back: the result prints without anage.