Formats over regexes
Validate input strings with Zod 4's named format builders, like email, uuid, and ISO datetime, instead of hand-rolled regexes.
In the last lesson you assembled an invoice-creation schema with one field left as a placeholder: email: z.string().
That promises almost nothing.
z.string() accepts any string, so "ada@example.com", "not-an-email", and "banana" all parse: the schema knows the field is a string, not that it’s an email.
The gap widens in a real sign-up, which carries more than an email.
An invitation token is a UUID a teammate was sent.
A requested start date is an ISO 8601 timestamp off a date picker.
A client IP, read from the request headers, keys a rate limiter.
Type each as z.string() and a forged token, a date typed as "tomorrow", and an IP of "localhost" all pass, because every one is technically a string.
If you’ve worked in other languages, your instinct is to reach for a regular expression.
Set it aside.
Zod 4 ships a named builder for every common kind of string, including z.email() , z.uuid(), z.iso.datetime(), and z.ipv4().
By the end of this lesson you’ll write the format half of any input schema, reaching for the right builder per field instead of a regex.
Use top-level format builders, not chained checks
Section titled “Use top-level format builders, not chained checks”Start with the email, because how you write it changed between Zod 3 and Zod 4.
In Zod 3 you wrote z.string().email(): a string schema with a format check chained onto it.
The format is a postfix modifier, bolted on after the fact, so the field’s real type, “an email,” is expressed as “a string, plus a check.”
Zod 4 promotes the format to its own top-level builder, z.email().
There’s no z.string() underneath and no chained method.
It still infers as string, because an email is a string, but the format is now the builder rather than a modifier.
Both forms parse the same valid emails; what differs is where the format lives.
const signupSchema = z.object({ name: z.string(), email: z.string().email(),});What you’ll meet in older codebases, and what an AI may still emit. Deprecated in v4. The format is chained onto a string schema as a postfix check. It still parses, with a deprecation warning, but it’s no longer the form you write.
const signupSchema = z.object({ name: z.string(), email: z.email(),});The format is the builder. Infers as string, ships its own error message, tree-shakes. There’s nothing to chain off; the email-ness is declared where the type is declared.
The top-level form wins on three counts, and the same reasons make it the right reflex everywhere else in this lesson.
First, bundle size.
z.string().email() pulls in the whole ZodString class, with every string method (.startsWith, .includes, and the rest), even when all you check is that the field is an email.
z.email() pulls in the email validator and nothing else, so when a build tool strips unused code the top-level builders tree-shake cleanly while the chain does not.
Second, the error message.
z.email() carries its own default, so a failed parse says "Invalid email" rather than a generic string complaint.
Because the format knows what it is, it knows what to say when the input isn’t it, and that message becomes part of the contract the form layer renders.
Third, each top-level builder emits a cleaner JSON Schema, which matters once a later unit generates an OpenAPI document from these schemas.
Now put it to work.
Below is the sign-up schema as the last lesson left it, where every field that’s really a format is still a bare z.string().
Swap each one for its top-level builder so the bad inputs start failing.
Watch the ^? query: the inferred type stays string throughout, because naming the format doesn’t change the type, only the runtime check tightens.
Replace each bare z.string() with the right top-level format builder — z.email(), z.uuid(), z.iso.datetime() — so the bad inputs start failing. Watch the ^? query: the inferred type stays string for each field. Naming the format doesn't change the type; it tightens the runtime check.
| Test scenario | Value | |
|---|---|---|
| valid signup | {"email":"ada@example.com","token":"0190d3a2-7b4e-7c1a-9f… | |
| bad email | {"email":"not-an-email","token":"0190d3a2-7b4e-7c1a-9f3d-… | |
| bad token | {"email":"ada@example.com","token":"nope","startAt":"2026… | |
| bad datetime | {"email":"ada@example.com","token":"0190d3a2-7b4e-7c1a-9f… | |
With the rule in hand, here is the catalog of formats it points you at.
The format catalog for SaaS inputs
Section titled “The format catalog for SaaS inputs”A web app reaches for about a dozen of these builders. Here is the working set, grouped by the kind of field, so you know a builder exists and reach for it instead of a regex.
z.email() is an RFC-aligned email, and its default is right for the common case.
The one knob is a pattern option for a platform whose deliverability rules demand a stricter shape: z.email({ pattern: z.regexes.html5Email }), or your own regex.
z.uuid() versus z.guid() is the distinction worth slowing down for, because it’s easy to get wrong and it changed in v4.
Both validate the 8-4-4-4-12 hex shape, but to different degrees of strictness.
z.uuid(); // strict: RFC 9562/4122, versions 1–8, variant bits correctz.guid(); // permissive: any 8-4-4-4-12 hex string
z.uuid({ version: 'v7' }); // pin to exactly UUIDv7z.uuid() is strict in v4: it enforces a recognized version (1 through 8) and correct variant bits. Reach for it with IDs your own app generates. The UUIDv7 primary keys from earlier in the course pass cleanly, because your app produces spec-correct UUIDs.
z.uuid(); // strict: RFC 9562/4122, versions 1–8, variant bits correctz.guid(); // permissive: any 8-4-4-4-12 hex string
z.uuid({ version: 'v7' }); // pin to exactly UUIDv7z.guid() is permissive: any 8-4-4-4-12 hex string passes, version and variant bits unchecked. Reach for it only when an upstream system hands you identifiers that may not be RFC-strict and you can’t change that.
z.uuid(); // strict: RFC 9562/4122, versions 1–8, variant bits correctz.guid(); // permissive: any 8-4-4-4-12 hex string
z.uuid({ version: 'v7' }); // pin to exactly UUIDv7To require exactly one version, pin it. Since your app standardized on UUIDv7, z.uuid({ version: 'v7' }) rejects a v4 UUID that slipped in from elsewhere.
The migration trap: Zod 3’s z.string().uuid() was loose, accepting any 8-4-4-4-12 hex with the version unchecked.
That loose validator maps to v4’s z.guid(), not to z.uuid().
So mechanically rewriting an old z.string().uuid() to z.uuid() quietly tightens the check, and code that worked in v3 can start rejecting identifiers.
Rewrite to z.uuid() when you control the IDs, and to z.guid() when you don’t.
z.url() is a URL-constructor-compatible URL, and by itself it accepts javascript: and data: URLs.
For any URL your app renders as a link or redirects a user to, that’s the open-redirect and XSS class of bug, so the production reflex is the protocol allowlist, not bare z.url().
z.url({ protocol: /^https?$/ }); // must be http or httpsz.url({ protocol: /^https?$/, hostname: /\.example\.com$/ });
z.httpUrl(); // shorthand for the http/https caseThe protocol regex constrains the scheme, an optional hostname regex pins it to domains you trust, and z.httpUrl() is shorthand for the http/https case.
Treat the allowlist as non-negotiable for any user-supplied URL the app acts on; the full open-redirect rule comes later in the course.
ID encodings: z.cuid(), z.cuid2(), z.ulid(), z.nanoid().
These are alternative ID string formats, each produced by a particular family of generators.
The rule is mechanical: use the format the upstream system produces.
If a third-party service hands you CUID2 IDs , validate with z.cuid2(); if it emits nanoids, use z.nanoid().
z.ipv4(), z.ipv6() validate IP address strings, for example a client IP you key a rate limiter on.
Reading that IP safely off the request is a request-surface concern covered later; here these are just the validators for the string once you have it.
For a CIDR block rather than a single address, use z.cidrv4() and z.cidrv6().
z.iso.date(), z.iso.time(), z.iso.datetime(), z.iso.duration() are ISO 8601 string validators.
The easy-to-miss point: these validate the string format and infer as string, not Date.
z.iso.datetime() confirms a value looks like "2026-09-01T10:00:00Z"; turning the validated string into a Date is a separate step, covered later in this chapter.
By default it rejects timezone offsets, with options for them ({ offset: true }) and for sub-second precision ({ precision }).
z.jwt() validates that a string has the shape of a JWT .
You’ll rarely write it, because auth flows in a later unit verify tokens through the auth library, which checks signature and claims, not just shape.
z.e164() validates the phone-number format: E.164 is a leading + and up to fifteen digits, and z.e164() checks that and stops there.
Full phone parsing with libphonenumber is a parser, not a schema concern, and is out of scope.
Note that you wrote no regex: every one of those is a string of a known kind, and every kind had a builder.
Numbers and dates take constraint chains
Section titled “Numbers and dates take constraint chains”A format is a builder that knows a kind of string. Numbers and dates have no format to name, so you constrain the range of allowed values with a chain of methods instead. The idea is the same; the ergonomics differ.
In Zod 4, z.number() rejects NaN and Infinity by default.
z.number().parse(3); // ✓ 3z.number().parse(NaN); // ✗ rejected by default in Zod 4z.number().parse(Infinity); // ✗ rejected by default in Zod 4This changed from Zod 3, where both slipped through and you added .finite() to keep them out.
In v4 .finite() is redundant, so if you see it in older code or an AI suggests it, that’s a v3 habit you can drop.
The constraints chain inline on top of z.number().
.min(0) and .max(100) bound the range; .gt() and .lt() give the open comparisons, with .gte() and .lte() for the inclusive ones (aliased by .min and .max); .int() demands a whole number; .positive() and .nonnegative() cover the sign; and .multipleOf(0.01) snaps to a step.
Compose the ones the field needs.
Two combinations come up constantly, so learn them as whole shapes.
z.number().int().positive(); // a quantity: 1, 2, 3 — never 0, never 2.5z.number().positive().multipleOf(0.01); // a money amount: 19.99, never -5 or 19.999z.int().positive(); // same as z.number().int(), clearer at the call site
z.date().min(new Date('2020-01-01')); // a Date instance on or after that dayA quantity is z.number().int().positive(): a whole number, one or more, the shape the last lesson’s invoice schema used.
A money amount is z.number().positive().multipleOf(0.01): positive and snapped to two decimal places, so 19.999 is rejected.
Treat that money shape as a schema-level approximation only.
Real money is a deeper story, since databases hand back monetary columns as strings to avoid floating-point drift and production code reaches for a decimal library, covered later in this chapter.
Here, multipleOf(0.01) is exactly as much correctness as a validation schema needs.
z.int() is the top-level form of z.number().int().
When a field is conceptually an integer, like a count, an age, or a page number, prefer z.int(): it says so more directly at the schema site.
For dates, z.date() validates an actual Date instance, not a string, and takes ranges with .min(new Date('2020-01-01')) and .max(...).
It is easy to confuse with z.iso.datetime() from the catalog, and telling them apart is how you choose between them.
const schema = z.iso.datetime();
schema.parse('2026-09-01T10:00:00Z');// ✓ → '2026-09-01T10:00:00Z' (a string; infers as string)The value arrives as a string, such as a URL param or a JSON body field, and stays one. Reach for this when the date crosses the wire as text and the next thing done to it is not date math.
const schema = z.date();
schema.parse(new Date('2026-09-01T10:00:00Z'));// ✓ → Date instance (infers as Date)The value is already a Date object in memory, and you’ll do date math on it. Reach for this when the consumer formats it, compares it, or does timezone work in JavaScript.
The choice is mechanical: a string off the wire takes z.iso.datetime(); a Date already in memory takes z.date().
Bridging from a validated ISO string to a Date is its own step, covered later in this chapter.
Finally, z.bigint() accepts a bigint and takes the same range constraints.
Recall that a bigint doesn’t JSON.stringify cleanly, so it needs care the moment it crosses a JSON boundary.
String constraints and the regex of last resort
Section titled “String constraints and the regex of last resort”A format builder names a kind of string. Other useful checks aren’t about the kind at all but about length, prefix, or casing. Those layer on as a constraint chain, just like the number constraints.
The string constraints are .min(n), .max(n), and .length(n) for length; .startsWith(), .endsWith(), and .includes() for substrings; .regex(/.../) for a custom pattern; and the normalizers .trim(), .toLowerCase(), and .toUpperCase().
Two points matter most.
Formats compose with constraints.
A format builder is still a string schema underneath, so you can chain a length check onto it.
A real sign-up email isn’t bare z.email(); it’s z.email().max(254).
The format validates the kind, and .max(254) defends against someone pasting a megabyte into the field to see what breaks.
Make it a reflex: a max length is cheap insurance on any free-text field a stranger can submit.
z.email().max(254); // the kind, plus a defense against pathological lengthz.string().min(1).max(80); // a display name: present, and boundedNormalizers change the value, not just check it.
.trim() strips surrounding whitespace; .toLowerCase() lowercases.
The inferred type stays string, but the value you get back from a parse is not the value you put in: parse " ADA@EXAMPLE.COM " through z.email().toLowerCase().trim() and you get "ada@example.com".
So never expect a parsed value to equal its input when a normalizer is in the chain.
The general machinery for transforming values is the next lesson’s subject.
The main rule: reach for .regex() only when no built-in format fits.
A named builder wins on four counts:
- It’s tested against thousands of real-world inputs, including the unusual-but-valid emails and edge-case URLs a regex you write in five minutes will miss.
- It’s internationalized: the email and URL validators handle Unicode and international cases a naive ASCII regex silently rejects.
- It’s kept current: when a spec evolves, the builder updates with it, while your regex is frozen the day you wrote it.
- It produces a better error message:
"Invalid email"reads to a user;"does not match /^[^@]+@..../"reads to no one.
“Last resort” isn’t “never.”
Some shapes have no built-in, and for those .regex() is exactly right.
z.string().regex(/^SKU-\d{6}$/); // an internal SKU — no built-in format for thisAn internal SKU like SKU-000123 is a shape your business invented.
No spec defines it, so no builder validates it, and z.string().regex(/^SKU-\d{6}$/) is the correct tool.
A regex earns its place only where the format is genuinely yours and nobody has standardized it.
Now sort each field below by whether Zod 4 ships a named builder for it, or whether it’s a genuine last-resort regex.
Sort each field by whether Zod 4 ships a named builder for it, or whether it's a genuine last-resort regex. Drag each item into the bucket it belongs to, then press Check.
SKU-00012394105-12341.4.2What schemas validate, and what belongs in the action
Section titled “What schemas validate, and what belongs in the action”You can now write the format half of a sign-up schema, and half is the right word.
A schema validates shape and format: everything provable from the value alone.
Is this a well-formed email? Is it under 254 characters? Is it lowercased?
Layer on a .max(254) and a .toLowerCase(), and the schema’s job is done.
A sign-up needs more than that, and the rest is a different kind of question. Is this email already registered? Is it on a suppression list of addresses that bounced or complained? Does the requested org slug collide with one that exists? None of these is a format rule; each needs a database lookup, because you can’t answer “is this email taken” from the email’s text.
That’s the boundary. A schema that needs a database connection to parse is the failure mode: it can’t run in a test or at the edge, and it tangles validation with live infrastructure. Those rules live in the Server Action’s body, after the parse, the very next chapter and exactly the layer where reaching into the database is legitimate.
Where to go deeper
Section titled “Where to go deeper”The Zod documentation is the full catalog this lesson selected from. The schema-definition reference lists every format builder; the v4 changelog covers the string-format migration in Zod’s own words.