Skip to content
Release notesForms

Zod 4 is stable: faster parsing and what changes in your schemas

Zod 4.0.0 was published on 9 July, after two months living at the zod/v4 subpath inside 3.25. Your form schemas keep working; the way you customise their error messages does not.

vuejs.school team15 Jul 20257 min readZod 4.0 · Vue 3.5

Zod 4.0.0 was published to npm on 9 July. Zod is a schema library: you describe the shape of your data once and get both a TypeScript type and a runtime check from the same lines. The new major parses faster, typechecks faster and ships a smaller bundle. It also changes how custom error messages are written, so a form schema that uses required_error or invalid_type_error no longer typechecks.

The release notes cover the new features and the migration guide lists the breaking changes. Before you upgrade, check one thing: a library that takes your schemas as input, such as VeeValidate's Zod adapter, may not accept Zod 4 yet. The last section shows how to hold at 3.25 until it does.

What shipped

The release notes lead with numbers from the project's own benchmarks. Parsing a string is 14.71 times faster than Zod 3, an array 7.43 times, an object 6.5 times. TypeScript work drops too: a simple .extend() call that cost the compiler more than 25,000 type instantiations now costs about 175, and a chain of .extend() and .omit() calls that took 4,000 ms to check takes 400 ms. The core bundle shrinks from 12.47 kB to 5.36 kB gzipped for a schema with one boolean.

The feature list is long. The ones a Vue app will meet first:

  • One error parameter replaces message, invalid_type_error, required_error and errorMap.
  • String formats become top-level functions: z.email(), z.uuid(), z.url(), z.iso.datetime(). The method forms such as z.string().email() still work but are deprecated.
  • z.strictObject() and z.looseObject() replace .strict() and .passthrough().
  • Recursive schemas use getters, with no type casting.
  • z.toJSONSchema() converts a schema to JSON Schema, and .meta() attaches metadata.
  • z.file() for file inputs, z.int() and the sized number formats, z.stringbool() for "true" and "off" style strings, template literal schemas, and z.prettifyError() for readable output.
  • Zod Mini, at zod/mini, is a tree-shakable variant with functions instead of methods and weighs 1.88 kB for the same test.

Which import gives you Zod 4

Zod 3.25.0, published on 19 May, already contained Zod 4 at the zod/v4 subpath, next to Zod 3 at the root. The versioning page explains why. Many libraries accept your schemas as input, and a Zod 3 type is not assignable to a Zod 4 type, so releasing a major straight away would have forced a chain of major releases on every one of them. With the subpath, libraries could support both with one peer range, and you could import whichever you wanted.

With 4.0.0 the root export becomes Zod 4 and Zod 3 moves to zod/v3. The subpaths stay, so three imports are now valid and all of them are stable:

import { z } from 'zod'      // Zod 4 since 4.0.0
import { z as z4 } from 'zod/v4' // also Zod 4, keeps working
import { z as z3 } from 'zod/v3' // Zod 3, for code you have not migrated

Move custom messages to the error key

The release notes say most of Zod 4's breaking changes are in error customisation. Zod 3 had four ways to attach a message, and the two that distinguished "missing" from "wrong type" are gone. A typical sign-up schema from a Zod 3 project:

src/schemas/signup.tsBreaks
import { z } from 'zod'

export const signupSchema = z.object({
  email: z
    .string({ required_error: 'Email is required' })
    .email({ message: 'Enter a valid email address' }),
  password: z.string().min(8, { message: 'Use at least 8 characters' }),
  newsletter: z.boolean().default(false),
})

On Zod 4 the message keys still compile (they are deprecated but accepted), and required_error fails to typecheck because it no longer exists in the parameter type. In a plain JavaScript file nothing fails: Zod ignores the key, and a missing email gets the default message, "Invalid input: expected string, received undefined".

The replacement is a single error key that takes a string or a function. The function receives the issue, and issue.input === undefined is how you tell a missing field from a wrong one:

src/schemas/signup.tsWorks
import { z } from 'zod'

export const signupSchema = z.object({
  email: z.email({
    error: (issue) => (issue.input === undefined ? 'Email is required' : 'Enter a valid email address'),
  }),
  password: z.string().min(8, { error: 'Use at least 8 characters' }),
  newsletter: z.boolean().default(false),
})

export type Signup = z.infer<typeof signupSchema>

Two other things moved here. z.email() is now a schema in its own right rather than a refinement on z.string(), and it accepts the same error option, so the message covers both "not a string" and "not an email". And .refine() now returns the schema's own type, so you can write z.string().refine(noSpaces).min(3) in that order; Zod 3 wrapped refined schemas in ZodEffects, which hid .min().

One precedence rule changed with it: an error set on the schema now wins over an error map passed to .parse(), which is the opposite of Zod 3.

Reading errors into a template

ZodError.format() and .flatten() are deprecated. Their replacements are standalone functions, z.treeifyError() for nested schemas and z.flattenError() for the flat, one-level object a form usually is. flattenError returns formErrors for issues at the top and fieldErrors, a map from field name to an array of messages, which is exactly the shape a template wants:

src/components/SignupForm.vue
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { z } from 'zod'
import { signupSchema, type Signup } from '@/schemas/signup'

const form = reactive({ email: '', password: '', newsletter: false })
const errors = ref<Partial<Record<keyof Signup, string[]>>>({})
const emit = defineEmits<{ submit: [data: Signup] }>()

function submit() {
  const result = signupSchema.safeParse(form)
  if (!result.success) {
    errors.value = z.flattenError(result.error).fieldErrors
    return
  }
  errors.value = {}
  emit('submit', result.data)
}
</script>

<template>
  <form novalidate @submit.prevent="submit">
    <label for="email">Email</label>
    <input id="email" v-model="form.email" type="email" :aria-invalid="Boolean(errors.email)">
    <p v-if="errors.email" role="alert">{{ errors.email[0] }}</p>

    <label for="password">Password</label>
    <input id="password" v-model="form.password" type="password">
    <p v-if="errors.password" role="alert">{{ errors.password[0] }}</p>

    <label><input v-model="form.newsletter" type="checkbox"> Send me the newsletter</label>
    <button type="submit">Create account</button>
  </form>
</template>

Each field maps to an array because one value can fail more than one check; the template shows the first message. novalidate turns off the browser's own email check, so the message people see is Zod's.

safeParse is unchanged: it returns { success, data } or { success, error } and does not throw on invalid input. result.data is typed as Signup, with newsletter a boolean rather than boolean | undefined, because .default() applies to the output type. If you were reading error.errors, switch to error.issues; the alias was removed.

Objects, defaults and coercion

The object changes are small but they can alter parsed output, so read them before upgrading a schema that guards an API.

.strict() and .passthrough() are deprecated in favour of z.strictObject() and z.looseObject(), which say what they do at the point you read the schema. .merge() is deprecated in favour of .extend(). .deepPartial() and .nonstrict() are gone.

Defaults inside optional fields now apply: z.object({ a: z.string().default('x').optional() }) parses {} to { a: 'x' }, where Zod 3 returned {}.

.default() also short-circuits: when the input is undefined it returns the default without running the rest of the schema, so the default must match the output type. If you relied on the default being parsed, for example a date string that a transform turns into a Date, use .prefault(), which keeps the old behaviour:

const settings = z.object({
  theme: z.enum(['light', 'dark']).default('light'),
  fontSize: z.coerce.number().default(16),
  updatedAt: z.string().transform((s) => new Date(s)).prefault('1970-01-01'),
})

settings.parse({}) gives theme: 'light', fontSize: 16 and updatedAt as a Date for 1 January 1970: the prefault string went through the transform, while the two defaults were returned as they are.

The input type of every z.coerce schema is now unknown, where Zod 3 used the target type (number for z.coerce.number()). The key is still required: a missing fontSize would become NaN and fail, so the .default(16) is what makes it optional.

Strings, numbers, enums and arrays

Beyond the move to top-level formats:

  • z.uuid() is stricter and checks the version and variant bits. Use z.guid() for any 8-4-4-4-12 hex string.
  • .ip() and .cidr() are gone. Use z.ipv4(), z.ipv6(), z.cidrv4() or z.cidrv6().
  • z.number() no longer accepts Infinity.
  • .int() only accepts safe integers, and z.int() is the recommended spelling.
  • z.nativeEnum(Role) is deprecated; z.enum(Role) now accepts a TypeScript enum directly.
  • .nonempty() on an array now behaves like .min(1), so z.array(z.string()).nonempty() infers string[] instead of [string, ...string[]]. Code typed against the tuple needs z.tuple([z.string()], z.string()).

What stays the same

z.object, z.string, z.number, z.array, z.union, .optional(), .nullable(), .min(), .max(), .transform(), .refine(), parse, safeParse, parseAsync and z.infer all read and behave as before for everyday use. A schema file that never customised messages will likely compile unchanged, and deprecated methods such as .strict() and .merge() keep working. Zod 3 itself stays available at zod/v3, and the versioning page says it will keep getting bug fixes.

Upgrade, or hold at 3.25

The upgrade itself is one command:

npm install zod@^4.0.0

Before you run it, check what else in your project depends on Zod. In a Vue form that is often VeeValidate's adapter. Its current release, @vee-validate/zod 4.15.1, declares a peer dependency of zod@^3.24.0. Installing zod@4 next to it makes npm stop with a peer dependency conflict, and forcing past that hands the adapter schemas of a type it was not written for. The same applies to any other package that accepts your schemas as input.

If one of them does not accept 4.0.0, do not upgrade the root yet. Install zod@^3.25.76, keep the schemas you pass to the adapter importing from zod, and write new or standalone schemas against zod/v4. The versioning page says that import keeps working forever, so those files need no change when you upgrade later; only the schemas you pass to the adapter will need the edits above.

If only your own code uses your schemas, upgrade now. TypeScript flags the required_error, invalid_type_error and errorMap keys left to rewrite; message keys still work and can move to error when convenient. Then run your tests: the default, UUID and Infinity changes above can alter a result without any compile error.

Check it stuck3 questions from the Schema validation with Zod quiz.
Practice now
Was this helpful?

Keep reading