Lessons from Upgrading a Large TypeScript App to Zod 4
We recently upgraded a large application from Zod 3 to 4. The performance improvements are great and apart from one or two things, the migration was straightforward. Here are a few things we learned along the way.
.pipe(..) is much stricter #
If you make liberal use of .pipe() as a way to compose schemas then, like us, you might find yourself staring at an issue like:
The types of _zod.input are incompatible between these types: <T1, T2>
If you have code like:
const schema = z.object({
q: z.string(),
sort: z
.string()
.transform((value) => value.split(':'))
.pipe(someOtherSchemaExpectingValidSortParam)
});
A few different folks have started up discussions around the change. As Colin points out, “Zod schemas aren't contravariant in their input type” which is an intentional choice but it does create some edge cases like this.
The change to make .pipe() stricter in v4 is “intentional to fix unsoundness in v3”. It should help us more easily discover if we’re creating pipelines that may not behave as expected but it also comes with the need for workarounds while we migrate our projects to v4.
| ⚠️ What does “Zod schemas aren't contravariant in their input type” mean? |
|---|
| That’s an entire other separate blog post, I’m afraid. If you’d like to explore further I’d start with the wikipedia article on Contravariance, brush up on your Liskov Substitution Principle, and then look back at the classic Animal > Dog example which should make a bit more sense. |
Workaround 1: Return unknown from .transform(..) #
You can do this either by annotating the transform function’s return type or type casting the value and letting inference handle it.
const schema = z.object({
q: z.string(),
sort: z
.string()
.transform((value): unknown => value.split(':'))
// Or ━┑
// .transform((value) => value.split(':') as unknown)
.pipe(someOtherSchemaExpectingValidSortParam)
});
Workaround 2: Use z.any() explicitly #
This essentially opts out of any kind of type checking at that particular interface which can be a good short-term solution if you need it but should be avoided if possible.
const schema = z.object({
q: z.string(),
sort: z
.string()
.transform((value) => value.split(':'))
.pipe(z.any().pipe(someOtherSchemaExpectingValidSortParam))
});
.superRefine() is now .check() #
Allegedly check is just superRefine but with a “cleaner API”. No more return z.NEVER and z.ZodIssueCode.custom. Instead of ctx.addIssue() you just have to ctx.issues.push(..). Just kidding about z.NEVER by the way… apparently it’s still used in pipes and transforms to “exit the transform without impacting the inferred return type”.
This is an interesting choice. We can only speculate about the trade-offs that might have been considered when making this change. There are benefits to building APIs like .refine() by composing lower level and more versatile APIs (like .check()) for things like code simplicity, testability, and overall cohesiveness.
In terms of the user API, deciding to favor direct data manipulation (ctx.issues.push(..)) over function calls (ctx.addIssue(..)) seems like a step back in terms of level of abstraction. To use the API you need to know that “adding an issue” requires pushing a new issue value onto a list called ctx.issues. Those details leaking out may be a benefit if the goal is to add friction and discourage users from reaching for these specific escape hatches.
In practice, we only ever use .superRefine in a few spots and the migration was painless, so let’s all just agree to calm down and see how this one plays out, ok?
Working with ZodErrors is a bit different #
Instead of error.flatten() we now have z.flattenError(error). Instead of error.format() we now have z.treeifyError(error).
If you want a nice single string that captures all the problems on a schema you can now use z.prettifyError(error) and it will pretty-print a summary of all the (possibly nested) issues like this:
✖ Unrecognized key: "extraKey"
✖ Invalid input: expected string, received number
→ at username
✖ Invalid input: expected number, received string
→ at favoriteNumbers[1]
Migrating away from .merge()? Do this: #
The deprecation message on .merge() suggets replacing it with a.extend(b.shape). However, the zod 4 documentation for .extend() goes on to say that there is another form which has better tsc performance (using destructuring):
const foo = z.object({
foo: z.string(),
});
const bar = z.object({
bar: z.string(),
});
// zod 3
const fooBar = foo.merge(bar);
// zod 4 (based on deprecation message suggestion)
const fooBar = foo.extend(bar.shape);
// -- just do this instead: -v
// ✅ zod 4 (best performing, according to docs)
const fooBar = z.object({
...foo.shape,
...bar.shape,
});
z.string() refinement helpers promoted #
A bunch of helpers that were previously on ZodString (like z.string().email()) were promoted to top-level schema types (z.email()).
With that come a few adjustments to the level of strictness for these checks. z.uuid() will now validate more strictly against RFC 9562/4122 (specifically, the variant bits must be 10 per the spec). For something a little more “UUID-ish” you can use z.guid().
z.coerce input is now unknown #
z.coerce was changed so that the inferred input type is now unknown. This makes a lot of sense and in at least one case meant that we could remove a workaround that was put in place just so that we could get a correctly inferred input type that was accurate in how permissible it was.
Case Study: tsc metrics comparison #
After having gone through this migration on a fairly large project (~100k LoC) that makes significant use of zod schemas, here are the numbers:
| Metric (agg) | Before | After | Change |
|---|---|---|---|
| Identifiers | 3,534,744 | 3,373,893 | -160,851 (-4.55%) |
| Symbols | 4,486,388 | 4,364,030 | -122,358 (-2.73%) |
| Types | 1,193,186 | 1,028,579 | -164,607 (-13.80%) |
| Instantiations | 16,421,354 | 14,147,656 | -2,273,698 (-13.85%) |
| Memory used | 2,747,064K | 2,630,832K | -116,232K (-4.23%) |
| Build time* | 40.07s | 37.50s | -2.57s (-6.41%) |
Well we didn’t quite hit the 100x reduction in type instantiations claimed in the zod 4 introduction but a ~14% improvement is still significant.
For this project, the inciting incident that precipitated the need to urgently prioritize these updates was a problem with a few particularly deeply nested discriminated unions. We were hitting TS2589 errors (“Type instantiation is excessively deep and possibly infinite”) when trying to use the inferred zod schema types as generic parameters to react-hook-form’s useWatch (and useForm, although you can omit it and rely on inference if you like).
This isn’t the first time we’ve invested in improving the TypeScript performance on this project and I’m sure it won’t be the last. Every effort leaves a trail of lessons, frustrations, and small joys. Come back once in a while, and I’ll tell you another story.