Zod 4.5 shipped. I would compile Ask Ayush, not every DTO in Lahebo

Friday’s Zod 4.5 post is the validation story this week. I would compile the four schemas this site already parses, then stop. Here is the five-step check before I bump a lockfile.

I opened Colin McDonnell’s Zod 4.5 post on Friday and then I opened server/chat.ts. That was the useful order of events. The announcement is full of helpers — credit cards, deepPartial, nine new locales. This site has four Zod objects. They run on every Ask Ayush message and every hire form. I would compile those. I would not start a Zod tour of Lahebo because a changelog used the word 9x.

This portfolio still sits on Zod 3.25. The 4.5 features live on Zod 4. So the work is a major bump plus two function calls, not a rewrite of every DTO I left on class-validator in the Nest 12 note. Ask Ayush already has a request schema and a name-check schema. The contact form and the chat visitor live in server/contact.ts. Those are the files. The rest of the post is marketing until those four parse calls change.

A compiled schema can be used exactly like an uncompiled one. There are no special rules around compiled schemas. They're just faster.

Colin McDonnell, Zod 4.5, 28 August 2026

Lahebo is where I would try Standard Schema on one Stripe seat. This site is where I would try z.compile() first, because I can hit /api/chat and /api/contact without waking a billing database. Do not mix those two upgrades in the same hour.

Step 1: Name the schemas that already run on every request

Do not start from the 4.5 feature list. Start from the parse calls. On this repo that is chatRequestSchema and nameCheckSchema in server/chat.ts, and contactFormSchema and chatVisitorSchema in server/contact.ts. If a schema is only in a blog code sample, leave it. If a schema is in Lahebo and you have not dry-run Nest 12, leave it. Write the four names down. That is the whole upgrade surface for this weekend.

import * as z from "zod";

const chatRequestSchema = z.object({
  messages: z
    .array(
      z.object({
        role: z.enum(["user", "assistant"]),
        content: z.string().trim().min(1).max(2000),
      }),
    )
    .min(1)
    .max(12),
  stream: z.boolean().optional(),
  visitor: z
    .object({
      name: z.string().trim().min(1).max(80),
      country: z.string().trim().min(1).max(80),
    })
    .optional(),
});

That object is small, but it is an array of objects plus an optional visitor. Colin’s bench moves most on objects, arrays, and unions. This is the shape that benefits. A lone z.string() on a health check does not.

Step 2: Compile those four. Leave the rest of the tree alone.

z.compile(schema) returns a schema you keep using as parse and safeParse. I would wrap the existing object, not invent a second type. I would not import "zod/compile" from src/main.tsx. These parsers run in Vercel functions. A Vite entry preload never sees them.

import * as z from "zod";

const ContactForm = z.object({
  name: z.string().trim().min(1, "Name is required").max(200),
  email: z.string().trim().email("Invalid email address").max(320),
  subject: z.string().trim().min(1, "Subject is required").max(300),
  message: z.string().trim().min(1, "Message is required").max(5000),
});

export const contactFormSchema = z.compile(ContactForm);
export type ContactFormData = z.infer<typeof contactFormSchema>;

Compile next to the schema, not in the SPA entry import "zod/compile" auto-compiles the first parse. That is fine for a Node process that loads schemas after the preload. Ask Ayush is api/chat.ts importing server/chat.ts. If you want the side-effect import, put it at the top of the API file, before the server module. Otherwise just call z.compile() and move on.

Step 3: Use z.validate() only when you throw the output away

The name check is the one place I would reach for z.validate(). handleNameCheck already returns { ok: false } on a bad body. It does not show Zod issues in a toast. Building a ZodError there is wasted work. Colin says a reject is up to 16x cheaper if you skip that Error. The contact form is the opposite: sendContactEmailServer joins issue messages for the client. That stays on safeParse().

const nameCheckSchema = z.compile(
  z.object({
    name: z.string().trim().min(1).max(80),
  }),
);

export async function handleNameCheck(
  body: unknown,
  ip = "unknown",
): Promise<{ ok: boolean }> {
  if (!z.validate(nameCheckSchema, body)) return { ok: false };

  const local = parseVisitorName(body.name);
  if (!local.ok) return { ok: false };
  // Gemini yes/no stays. Validate only replaced the failed safeParse.
}

Do not write if (!z.validate(schema, body)) return; then schema.parse(body) on the next line. That is two passes on the success path so you can say you used the new API. safeParse() on a compiled schema is enough when you need the data.

Step 4: Treat the 4.5 bug-fix list as a test list, not a feature list

The helpers I would not add this weekend: z.creditCard(), z.properties(), z.deepPartial(), .exactPartial(), z.toZod(), and eight locales. This form does not take a card. Ask Ayush does not take a URL instance. Hindi error copy is not how the on-site assistant answers. The fixes I would actually grep for: z.iso.datetime() now wants seconds, string length counts code points, z.ulid() rejects a leading letter, z.ipv6() no longer trusts new URL(). If none of those strings exist in the repo, the bump is boring. Boring is the goal.

rg -n "z\.iso\.datetime|\.min\(|\.max\(|\.length\(|z\.ulid|z\.ipv6" \
  server src --glob '*.ts'

# Hits that matter after a 4.5 bump:
# datetime without seconds  -> was accepted in 4.4
# emoji in a .min()/.length() bound -> now counts code points
# ULID starting with 8-z     -> now invalid
# No hits? Bump, compile the four schemas, ship.

Step 5: What I would copy from this post, and what I would not

The feed will move on to the next parser. The useful part is the same as last week. Name the four parse calls. Compile those. Leave the changelog extras in the tab you are not merging.

Zod 4.5 questions

What is new in Zod 4.5?

The headline is z.compile(), which pre-compiles a schema so later parses skip a lot of interpreter work. Colin also shipped z.validate() for a boolean check without building a ZodError, plus z.creditCard(), z.properties(), z.deepPartial(), .exactPartial(), z.toZod(), and locales such as Hindi and Bengali. Memory per schema dropped about 9x versus 4.4.

Should I compile every Zod schema in the app?

No. Compile the objects you parse on every request. On this site that is the Ask Ayush chat body, the name check, the contact form, and the chat visitor. A one-field string check is not worth a ceremony. Complex objects, arrays, and unions are the ones Colin’s numbers actually move.

When do I use z.validate() instead of safeParse()?

Use z.validate(schema, input) when you only need yes or no. It does not construct a ZodError, so a reject is cheap — up to 16x faster on invalid data. If you need the parsed object or the issue messages for a toast, keep safeParse(). Do not validate and then parse the same payload on the success path just to use the new helper.

Is Zod 4.5 a breaking upgrade from Zod 3?

Zod 4.5 sits on Zod 4. This portfolio still pins Zod 3.25. The jump is a major. Read the 4.x changelog before you bump Lahebo. The 4.5-only surprises that can reject old input are ISO datetimes that now require seconds, string .min/.max/.length counting Unicode code points, and a stricter ULID and IPv6. If you do not use those formats, the scary list is short.

Should I import zod/compile in a Vite portfolio?

Only if the schemas live in the same graph as that import. import "zod/compile" auto-compiles the first time a schema parses. Ask Ayush and the contact form run in Vercel functions, not in src/main.tsx. A side-effect import in the SPA entry does nothing for those routes. I would call z.compile() next to the schema, or preload zod/compile in the API entry.