router icon indicating copy to clipboard operation
router copied to clipboard

Zod 4 support for `@tanstack/zod-adapter` (specifically `fallback`)

Open ptts opened this issue 6 months ago • 3 comments

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

ptts avatar Jun 05 '25 12:06 ptts

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

carlbleick avatar Jun 05 '25 18:06 carlbleick

Yeah just tested this and it does appear that Zod v4 works via the Standard Schema support.

scotttrinh avatar Jun 05 '25 20:06 scotttrinh

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" />:

  • search parameter must be passed ✅
  • search.filter must 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:

  • search parameter must be passed ✅
  • ➡️ search.filter must be one of the 3 defined values ❌ (It's now typed as "newest" | "oldest" | "price" | Whatever.) (~Not sure where Whatever comes from, if that's from zod, standard schema or Tanstack Router~ Whatever comes 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),
});

ptts avatar Jun 06 '25 08:06 ptts

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.

ptts avatar Jul 16 '25 13:07 ptts

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? 😄

niba avatar Jul 16 '25 19:07 niba

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? 😄

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 😄

ptts avatar Jul 16 '25 19:07 ptts

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 avatar Jul 28 '25 21:07 theoludwig

@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

schiller-manuel avatar Jul 29 '25 16:07 schiller-manuel

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 avatar Aug 01 '25 09:08 cristianghita24

@cristianghita24 I created a PR for that https://github.com/TanStack/router/pull/4823

niba avatar Aug 01 '25 09:08 niba

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 avatar Aug 05 '25 09:08 florianmartens

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.

niba avatar Aug 05 '25 11:08 niba

@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 😆)

theoludwig avatar Sep 22 '25 20:09 theoludwig

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?

mkarajohn avatar Sep 23 '25 11:09 mkarajohn

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.

theoludwig avatar Sep 23 '25 11:09 theoludwig

The diff is something like this when upgrading from Zod v3 to v4:

Image

audunru avatar Sep 23 '25 19:09 audunru

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,
  }),

m9tdev avatar Sep 26 '25 09:09 m9tdev