router
router copied to clipboard
Zod 4 support for `@tanstack/zod-adapter` (specifically `fallback`)
Discussion: https://github.com/TanStack/router/discussions/4092
Which project does this relate to?
Router (@tanstack/zod-adapter)
Describe the bug
It appears that fallback from @tanstack/zod-adapter isn't ready for Zod v4.
More information about the expected behavior is in my comment below: https://github.com/TanStack/router/issues/4322#issuecomment-2948514758
Steps to Reproduce the Bug or Issue
import { z } from "zod/v4";
import { fallback, zodValidator } from "@tanstack/zod-adapter";
const searchParamSchema = z.object({
test: fallback(z.string().optional(), null).default(null),
// ^ ❗ Type Error at z.string().optional()
});
Type Error:
Argument of type 'ZodOptional<ZodString>' is not assignable to parameter of type 'ZodTypeAny'.
Type 'ZodOptional<ZodString>' is missing the following properties from type 'ZodType<any, any, any>': _type, _parse, _getType, _getOrReturnCtx, and 7 more.ts(2345)
Expected behavior
Zod 4 support
I have not tested it yet but both zod and tanstack router support standard schema, so I would assume it just works? The docs say arktype doesn't need an adapter because it supports standard schema… Might be wrong tho
Yeah just tested this and it does appear that Zod v4 works via the Standard Schema support.
I am talking about a more specific use-case.
Generally, Zod 4 search param validation works fine, both via zodValidator and also if just passed in without an adapter (through standard schema):
i.e., this works fine:
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod/v4"; // <-
export const Route = createFileRoute("/test")({
validateSearch: z.object({
sort: z.enum(["newest", "oldest", "price"]).catch("newest"),
}), // ✅ no type error
});
But, currently, I can do this:
import { createFileRoute } from "@tanstack/react-router";
import { fallback, zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
const searchSchema = z.object({
sort: fallback(z.enum(["newest", "oldest", "price"]), "newest"),
});
export const Route = createFileRoute("/test")({
validateSearch: zodValidator(searchSchema),
});
Now, when I link to that page via <Link to="/test" />:
searchparameter must be passed ✅search.filtermust be one of the 3 defined values ✅- when someone navigates to that page with an invalid param, it gracefully falls back to "newest" (no error) ✅
I want to replicate this behavior with Zod 4:
import { createFileRoute } from "@tanstack/react-router";
import { fallback, zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod/v4";
const searchSchema = z.object({
sort: z.enum(["newest", "oldest", "price"]).catch("newest"),
});
export const Route = createFileRoute("/test-route")({
validateSearch: searchSchema, // via Standard Schema support
});
But now, when I link to this route:
<Link to="/test" search={{ sort: "does_not_exist" }} /> // NO Type Error ❌
Expected behavior:
searchparameter must be passed ✅- ➡️
search.filtermust be one of the 3 defined values ❌ (It's now typed as"newest" | "oldest" | "price" | Whatever.) (~Not sure whereWhatevercomes from, if that's from zod, standard schema or Tanstack Router~Whatevercomes from zod) - when someone navigates to that page with an invalid param, it gracefully falls back to "newest" (no error) ✅
I do not get a type error for invalid values. This is what fallback used to solve, but fallback gives a type error when used with Zod 4.
Same behavior if I do the same thing with zodValidator but without fallback;
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod/v4";
const searchSchema = z.object({
// sort: fallback(z.enum(["newest", "oldest", "price"], "price"), "newest"), // cannot use fallback without a type error (see original post)
sort: z.enum(["newest", "oldest", "price"]).catch("newest"),
});
export const Route = createFileRoute("/test")({
validateSearch: zodValidator(searchSchema),
});
Update 2: My bad, I guess it takes a bit longer as it's still in Canary and should ship in zod 4.1.0
Update:
Nice! With the newest update from zod, fallback is not necessary anymore! 🎊
More infos here: https://github.com/TanStack/router/pull/4442#issuecomment-3078284064
tl;dr:
Just update to the most recent version of zod and use the schema directly with .catch() without the adapter.
export const Route = createFileRoute("/test")({
validateSearch: z.object({
sort: z.enum(["newest", "oldest", "price"]).catch("newest"),
}),
});
This just works now ✨: Typescript requires you to always pass a value for sort and at runtime invalid values gracefully fall back to newest.
Just to clarify, the fix is already in canary and should ship with zod 4.1.0 (or 4.0.6).
Have you installed canary version of zod @ptts, or is there some magic I’m missing? 😄
Just to clarify, the fix is already in
canaryand should ship with zod 4.1.0 (or 4.0.6). Have you installed canary version of zod @ptts, or is there some magic I’m missing? 😄
Oh man, I guess you're right... I was only testing the regular z.infer<typeof schema> and didn't consider that StandardSchemaV1.InferInput<typeof schema> returns a different type which still contains Whatever in 4.0.5 😔 Well, soon though! Sorry everyone if you also already eagerly refactored your codebase 😄
To be able to "default" to a value, we still (tested with Zod v4.0.10 and TanStack Start 1.130.3) need to use both .catch and .default for example:
export const Route = createFileRoute("/posts/")({
component: RouteComponent,
validateSearch: z.object({
sort: z
.enum(["newest", "oldest", "price"])
.default("newest")
.catch("newest"),
}),
})
Because, if we don't use .default, but only .catch, then all the links to this route will need to provide the sort search param, but if we don't provide a search param, it should just use the default value.
While it works to provide the default value twice, it would be best to have a solution where we can only provide the default value once, it would be more elegant.
@theoludwig we won't add a helper for that to start. this can live in userland. you might also report this to zod and ask for a helper / fix there
if the fallback behaviour from zod v4 is
z.whatever().default('').catch('')
// or
z.whatever().catch('')
could this please be updated in the documentation?
@cristianghita24 I created a PR for that https://github.com/TanStack/router/pull/4823
I'm a bit lost with this issue. But there's still a peer dependency to zod3. If the zod adapter is compatible would it not be useful to either widen or remove that?
https://github.com/TanStack/router/blob/main/packages/zod-adapter/package.json#L70
I'm a bit lost with this issue. But there's still a peer dependency to zod3. If the zod adapter is compatible would it not be useful to either widen or remove that?
https://github.com/TanStack/router/blob/main/packages/zod-adapter/package.json#L70
@florianmartens If you have Zod v3 in your project, then you use zod-adapter. If you have Zod v4, you remove zod-adapter from your dependencies and just write Zod schemas.
@schiller-manuel
@theoludwig we won't add a helper for that to start. this can live in userland. you might also report this to zod and ask for a helper / fix there
I agree, it should be the responsibility of Zod, not of TanStack. :+1:
I opened a issue on Zod repo: https://github.com/colinhacks/zod/issues/5162 but it got closed without explanation by @colinhacks
This issue can probably be closed? Or is it waiting the docs update still?
(sorry for the very late reply btw 😆)
I am kinda lost here. zod-adapter has a peer dependency on zod3.
Are we even supposed to be using zod4 with zod-adapter or we can drop it if we move to zod4?
I am kinda lost here. zod-adapter has a peer dependency on zod3.
Are we even supposed to be using zod4 with zod-adapter or we can drop it if we move to zod4?
You don't need to use @tanstack/zod-adapter with Zod v4.
The diff is something like this when upgrading from Zod v3 to v4:
I think I'm having a scenario where it did work with zod3 and the adapter, but not with zod4 unfortunately. First I had this:
export const collectionParamsValidator = z.object({
pageIndex: fallback(z.number().int().nonnegative(), 0).catch(0),
pageSize: fallback(z.number().int().positive(), 10).catch(10),
sorting: z
.array(z.object({ id: z.string().nonempty(), desc: z.boolean() }))
.optional()
.catch(undefined),
filter: z.string().optional().catch(undefined),
});
...
validateSearch: zodValidator(
z.object({
orderLists: collectionParamsValidator.default({}),
check: collectionParamsValidator.default({}),
}),
),
Which I've now converted to this:
export const collectionParamsValidator = z.object({
pageIndex: z.number().int().nonnegative().default(0).catch(0),
pageSize: z.number().int().positive().default(10).catch(10),
sorting: z
.array(z.object({ id: z.string().nonempty(), desc: z.boolean() }))
.optional()
.catch(undefined),
filter: z.string().optional().catch(undefined),
});
...
validateSearch: z.object({
orderLists: collectionParamsValidator.default({}),
check: collectionParamsValidator.default({}),
}),
But the .default({}) parts now error with the following:
Argument of type '{}' is not assignable to parameter of type '() => { pageIndex: number; pageSize: number; sorting?: { id: string; desc: boolean; }[] | undefined; filter?: string | undefined; }'.
To work around that I've changed it to this now, which may be a bit more verbose:
const collectionParams = z.object({
pageIndex: z.number().int().nonnegative().default(0).catch(0),
pageSize: z.number().int().positive().default(10).catch(10),
sorting: z
.array(z.object({ id: z.string().nonempty(), desc: z.boolean() }))
.optional()
.catch(undefined),
filter: z.string().optional().catch(undefined),
});
export const collectionParamsValidator = collectionParams
.optional()
.default(collectionParams.parse({}));
...
validateSearch: z.object({
orderLists: collectionParamsValidator,
check: collectionParamsValidator,
}),