We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide13 min read

Zod 4, schema validation and migrating from v3

Zod describes the shape of data and derives types from it. Version 4, the mini build for smaller bundles, and changes that break code quietly.

Zod 4, schema validation and migrating from v3

Zod describes the shape of data and checks whether what arrived from outside has that shape. Along the way it derives a type from the description, so one schema plays two roles at once: it protects at runtime and types at compile time.

That combination solves a problem types alone do not. An interface describing a server response is a declaration the compiler takes on faith. A schema is a check that either passes or does not, and the type follows from it automatically and cannot drift away.

The basic arrangement

Code
TypeScript
import { z } from 'zod'

const User = z.object({
  id: z.uuid(),
  email: z.email(),
  age: z.number().int().min(18),
  role: z.enum(['admin', 'editor', 'reader']),
})

type User = z.infer<typeof User>

const result = User.safeParse(dataFromServer)
if (!result.success) {
  console.error(result.error.issues)
} else {
  result.data.email
}

Two things in that code matter more than the rest.

The first is z.infer, deriving a type from the schema. That gives one source of truth instead of two, and a schema change immediately raises errors wherever code used the old shape.

The second is safeParse rather than parse. The first returns a result to check, the second throws. For user supplied data you almost always want the first, since an invalid form is not an exceptional situation but an ordinary path.

Where this library actually earns its keep

Worth naming the places where it pays, since adding schemas everywhere is a cost with no return.

The system boundary is the right place: a response from an external interface, an incoming request body, form data, environment variables at startup, the contents of a configuration file. In all of those the data comes from outside TypeScript, and a hand written type is nothing but a wish.

Environment variables deserve their own sentence, being the cheapest possible win. A schema checked at startup means a missing key stops the application immediately with a readable message, rather than breaking at three in the morning on the first request that needed it.

Code
TypeScript
import { z } from 'zod'

const Environment = z.object({
  DATABASE_URL: z.url(),
  STRIPE_SECRET: z.string().startsWith('sk_'),
  PORT: z.coerce.number().int().min(1).max(65535).default(3000),
  NODE_ENV: z.enum(['development', 'test', 'production'])
})

export const env = Environment.parse(process.env)

The coercion on the port matters, because environment variables always arrive as strings. Without it a schema expecting a number rejects a valid value, and the message talks about the type rather than the cause.

The inside of an application is the wrong place. Data that passed validation at the boundary already carries a type, and re-checking it between functions adds code and time for no benefit.

Version 4 and what changed in it

The fourth release landed in 2025 and is one where the important part is not new capability but three behavioural changes and one change to the package's shape.

The method merging schemas is now discouraged in favour of extending or spreading the shape. Worth stressing that it did not disappear: .merge() still works and merely carries a deprecation mark, so the compiler stops nothing and an editor at most strikes the name through. The authors give two reasons for the change: extending does not lose settings around excess fields, and it checks faster in the compiler.

Code
TypeScript
const Base = z.object({ id: z.uuid() })
const Stamp = z.object({ createdAt: z.iso.datetime() })

const Record = Base.extend(Stamp.shape)

Spreading the second schema's shape also works when fields overlap: the right hand version wins. That behaviour is worth knowing, because silently replacing a field's type while merging two schemas is a mistake the compiler will not report.

Error handling was unified: instead of several separate parameters there is one, shared across the schema. The number of reported problem kinds dropped from fifteen to eleven, which simplifies error processing code while requiring a pass over it.

The third change is the dangerous one, since it compiles without a word. It concerns default values and has two separate parts that are easy to confuse with each other.

The first concerns optional fields. A default written into a field marked optional now applies even when the key was absent from the input entirely.

Code
TypeScript
const Settings = z.object({
  theme: z.string().default('light').optional()
})

Settings.parse({})

In the previous version that call returned an empty object; in the fourth it returns an object with the field set to the default. Code checking for a key's presence rather than its value will therefore start seeing a field it never saw before.

The second part concerns transformations. On undefined input, validation now returns the default immediately without passing it through the schema, so that value has to match the output type rather than the input type. The schema z.string().transform((v) => v.length).default(0) returns zero, whereas its previous version counterpart with a 'tuna' default returned four. The new .prefault() method, meaning a pre-parse default, restores the old behaviour.

Those are the places to review by hand during migration, because no search term will find them.

Add to that a requirement for strict compiler settings and a newer TypeScript version, so a project on loose settings has to tighten them first.

Three variants in one package

The package's shape is unusual today, and that is what makes migration easiest.

The base variant is the familiar interface with chained methods. The mini variant exposes the same validators as standalone functions, letting a build tool strip everything you did not use. The third variant is the previous version, available under a separate import path.

That last point matters more than it looks. It lets both versions run side by side in one project, so migration proceeds file by file rather than in one sweep across a repository.

Code
TypeScript
import { z } from 'zod'
import { z as z3 } from 'zod/v3'
import * as zm from 'zod/mini'

const Current = z.object({ id: z.uuid() })
const Legacy = z3.object({ id: z3.string().uuid() })
const Light = zm.object({ id: zm.string() })

On a large project that is the difference between a week and an afternoon. The light variant is worth considering in code shipped to the browser, since it lets the bundler drop validators you never used.

The mini variant deserves consideration in code shipped to a browser. The library core itself shrank more than twofold in this version, and the functional variant adds the ability to drop unused validators on top. Server side that makes no difference, and there the chained form wins on convenience.

Transformations and two sided schemas

A schema need not only check. It can transform as well, and that capability saves considerable code at system boundaries.

Code
TypeScript
const Order = z.object({
  createdAt: z.string().transform((s) => new Date(s)),
  amountCents: z.number().int(),
}).transform((o) => ({
  createdAt: o.createdAt,
  amount: o.amountCents / 100,
}))

After such a transformation the input type and the output type differ, and the library distinguishes them with separate utilities. That distinction confuses until you hit a case where it matters: a description of data sent to a server needs the input type, while code working on the result needs the output type.

The typical use is converting strings into dates and numbers. An interface response arrives in textual form, since that is how serialisation works, and converting it once at the boundary beats converting in fifteen places.

Be careful, though, with transformations that change the meaning of data. A schema that rounds amounts or truncates strings while validating hides business logic where nobody looks for it. Checking and computing are two responsibilities, and mixing them bites on the first arithmetic bug.

Cross field checks are a separate capability. An end date later than a start date, or a password matching its confirmation, are rules about the whole object rather than a single field, and get expressed as an extra object level check together with a pointer to the field the message belongs to.

Error messages and multiple languages

The default messages are English and technical, so showing them to a user directly is a mistake visible in every application built in a hurry.

There are three routes. You can supply a custom message per rule, which gives full control and the most typing. You can set one message for a whole schema, which suffices for fields where the point is simply that something is required. You can also replace messages globally through the built in translation mechanism, which is the right route for a multilingual application.

That last option gets skipped, since it needs one time configuration and then works everywhere. Set it up at the start of a project, before a hundred hand written messages appear in the code that nobody will translate later.

In React forms, messages come back tied to a path to the field, so showing them beside the right input requires no mapping. In nested structures that path is an array rather than a string, and that detail trips most people on their first form with a list of items.

Zod against the alternatives

OptionSizeStylePick it when
ZodMedium, mini much smallerChained methodsThe default pick in a TypeScript project
ValibotVery smallComposed functionsBrowser code, fighting for kilobytes
Class based librariesLargeDecoratorsA backend with dependency injection
Hand written checksZeroOrdinary codeOne field, one format

The second row is the most serious competitor, and the gap between it and the mini variant narrowed enough that on a new project it pays to measure both against your own code rather than trusting comparisons.

The last row deserves honest consideration. Checking whether a field holds a number above zero needs no library, and a schema describing one three field form is often longer than the code it replaced. The value appears with derived types and nested structures.

Discriminated unions and conditional shapes

An object with a fixed set of fields is the simplest case, and most real data is not like that.

An interface response is either a result or an error. An event carries different fields depending on its type. A form shows different inputs once an option is picked. In all of those, describing things with one object full of optional fields is a lie, since it permits combinations that have no right to exist.

The right tool is a union discriminated by one field. You name the deciding field and list the variants, and the library checks only the variant that matches. The gain is twofold: checking runs faster, since it does not try every possibility in turn, and the error message points at the specific problem rather than returning failures from every variant at once.

The derived type is meanwhile a discriminated union in the compiler's sense, so once the deciding field is checked the remaining fields narrow by themselves. That is the same mechanism that keeps response handling code free of casts.

Remember one thing about recursive structures, a category tree for instance. The type has to be declared by hand and handed to the library, since automatic derivation cannot resolve a reference to itself. That is one of the few places where a manual annotation is necessary rather than redundant.

Working with the rest of the stack

This library's greatest strength comes from other tools being able to read from it.

With forms a schema serves both to check fields and to type values, so you never declare the shape twice. Error messages come back tied to specific fields, which lets you show them beside the right inputs with no manual mapping.

With tRPC a procedure's input schema plays a double role: it rejects invalid data and derives the argument type the client sees. That is the same mechanism as with forms, stretched across the network.

When working with language models a schema describes the expected response structure and lets you reject output that fails it. That is where runtime validation is indispensable, since a model can return something almost correct and a type will not catch it.

Note also that a schema can be converted to a description in a standard format and back. When generating interface documentation or working with tools outside this ecosystem, that saves maintaining two descriptions of the same thing.

Common mistakes

The first is using parse rather than safeParse on user supplied data. A thrown exception turns an invalid form into a server error.

The second is validating the same data repeatedly inside the application. After a boundary check the data carries a type, and further checking is a cost with no return.

The third is skipping environment variable validation. A missing key then surfaces on the first request that needed it rather than at startup.

The fourth is missing the default value behaviour change in version four. The code compiles without warning and behaves differently.

The fifth is describing an application's interior with schemas instead of its boundaries. Code and runtime grow while safety does not.

The sixth is importing the full variant into code shipped to a browser when the functional one suffices. On a simple form the bundle size difference is noticeable.

The seventh is describing variably shaped data with one object full of optional fields. The type then permits combinations that cannot occur, and the code either handles them or pretends they do not exist.

FAQ

How does Zod 4 differ from the previous version?

By three behavioural changes and a new package shape. The schema merging method was marked deprecated in favour of extension, though it still works, error handling was unified, and defaults now apply inside optional fields too and come back without being passed through the schema. The library core is meanwhile more than twice as small.

How do I migrate without downtime?

File by file. The previous version is available under a separate import path, so both can run side by side in one project. Give the most attention to places with default values, since that change will not surface as a compile error.

What is the mini variant?

The same set of validators exposed as standalone functions rather than chained methods. It lets a build tool remove everything you did not use, so it pays in code shipped to a browser. Server side the base variant is more convenient.

Does Zod slow an application down?

Checking costs time, but it runs at system boundaries, so usually once per request. The problem appears when validating the same data repeatedly inside the application, or checking very large arrays inside a request handling loop.

Do I need Zod when I already have types?

Yes, since types vanish at compile time. A server response described as an object of a given shape can in reality be anything, and the compiler will believe it. Runtime validation is the only check that actually happens.

Full documentation sits on the project site, and the fourth version's changes in the release notes.