TanStack Router 搜索参数校验模式全解析:Zod / Valibot / ArkType 与手动校验实战指南
2026/9/15 14:03:31 网站建设 项目流程

TanStack Router 搜索参数校验模式全解析:Zod / Valibot / ArkType 与手动校验实战指南

【免费下载链接】router🤖 A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router

本文是 TanStack Router 搜索参数(Search Params)校验的完整参考指南,系统梳理了validateSearch支持的全部校验方式——从 Zod 适配器、Valibot 与 ArkType 的 Standard Schema 直连,到手写校验函数与parse方法模式,并给出数组、嵌套对象、可辨识联合、日期等高频场景的实战代码。读完本文,你将能够为任意路由选择最合适的校验方案,并在源码层面理解各适配器的类型推导与底层实现。

validateSearch 与校验器类型体系

在 TanStack Router 中,搜索参数校验通过路由选项validateSearch声明。该选项定义在 packages/router-core/src/route.ts 的FilebaseRouteOptionsInterface中,其类型为:

validateSearch?: Constrain<TSearchValidator, AnyValidator, DefaultValidator>

这里的DefaultValidatorValidator<Record<string, unknown>, AnySchema>(见 packages/router-core/src/validators.ts)——也就是说,如果不提供任何校验器,搜索参数会被视为"未校验的任意键值对",此时在组件里读取Route.useSearch()只能拿到无类型保障的Record<string, unknown>。校验器的核心价值,正是把这份"不可信输入"转换为强类型的、可安全消费的数据结构。

从源码看,校验器是一个联合类型(packages/router-core/src/validators.ts),由以下四种形态组成:

export type Validator<TInput, TOutput> = | ValidatorObj<TInput, TOutput> // { parse: fn } | ValidatorFn<TInput, TOutput> // (input) => output | ValidatorAdapter<TInput, TOutput> // { types: { input, output }, parse } | StandardSchemaValidator<TInput, TOutput> // { '~standard': { types, validate } } | undefined

其中ValidatorAdapter是适配器(@tanstack/zod-adapter等)产出的形态,携带显式的types.input/types.output供类型推导;StandardSchemaValidator则是 Standard Schema 规范定义的形态,通过'~standard'键暴露typesvalidate(见 packages/router-core/src/validators.ts)。router-core 借助ResolveValidatorInput/ResolveValidatorOutput等工具类型(packages/router-core/src/validators.ts)从这几种形态中解出导航输入类型与读取输出类型,从而保证"导航时传入的字段"与"组件里读到的字段"分别拥有精确的静态类型。

理解了这个类型体系,下面各节的不同写法本质上都是在向Validator联合类型靠拢:适配器返回ValidatorAdapter,Standard Schema 库直接返回StandardSchemaValidator,手动函数直接就是ValidatorFn

使用 Zod:@tanstack/zod-adapter

为什么需要适配器,以及为什么用fallback()而非.catch()

Zod v3没有实现 Standard Schema,因此必须借助@tanstack/zod-adapter包提供的zodValidator()包装,才能让 zod schema 接入 router 的校验器类型系统。该适配器源码位于 packages/zod-adapter/src/index.ts。

同时,文档明确要求:默认值一律使用适配器导出的fallback(),而不是 zod 自带的.catch()。从源码看,fallback的实现是一个管道:

export const fallback = <TSchema extends z.ZodTypeAny>( schema: TSchema, fallback: TSchema['_input'], ): z.ZodPipeline< z.ZodType<TSchema['_input'], z.ZodTypeDef, TSchema['_input']>, z.ZodCatch<TSchema> > => { return z.custom<TSchema['_input']>().pipe(schema.catch(fallback)) }

它的关键在于:先用z.custom<TSchema['_input']>()把输入侧类型固定为 schema 的_input,再通过.pipe()接到schema.catch(fallback)上完成"解析失败则回退默认值"的运行时行为。这样既保留了"传入值可能是任意原始输入"的宽松输入类型,又保证了"只要解析成功,字段必然有值"的输出类型——这正是搜索参数场景最需要的语义。

基础类型

import { zodValidator, fallback } from '@tanstack/zod-adapter' import { z } from 'zod' const schema = z.object({ count: fallback(z.number(), 0), name: fallback(z.string(), ''), active: fallback(z.boolean(), true), }) export const Route = createFileRoute('/example')({ validateSearch: zodValidator(schema), })

zodValidator()在运行时返回{ types: { input, output }, parse }(见 packages/zod-adapter/src/index.ts),parse直接调用schema.parse(input);默认情况下input类型取 schema 的_inputoutput类型取_output,对应"导航时传原始值、读取时得到已校验值"的默认约定。

可选参数

const schema = z.object({ // Truly optional — can be undefined in component searchTerm: z.string().optional(), // Optional in URL but always has a value in component page: fallback(z.number(), 1).default(1), })

注意区分两种"可选":z.string().optional()表示字段可以完全不存在,组件里读到undefined;而fallback(z.number(), 1).default(1)表示 URL 里可以缺省,但读取时由于有默认值回退,组件里始终能拿到number类型

默认值

const schema = z.object({ // .default() means the param is optional during navigation // but always present (with default) when reading page: fallback(z.number(), 1).default(1), sort: fallback(z.enum(['name', 'date', 'price']), 'name').default('name'), ascending: fallback(z.boolean(), true).default(true), })

.default()的语义是:导航时可以省略该参数,但读取时总是存在(带上默认值)。当fallback.default()组合使用时,外层.default()负责导航侧的缺省补齐,内层fallback负责 URL 中出现非法值时的兜底。

数组参数

const schema = z.object({ tags: fallback(z.string().array(), []).default([]), selectedIds: fallback(z.number().array(), []).default([]), }) // URL: /items?tags=%5B%22react%22%2C%22typescript%22%5D&selectedIds=%5B1%2C2%2C3%5D // Parsed: { tags: ['react', 'typescript'], selectedIds: [1, 2, 3] }

搜索参数在 URL 中以 JSON 字符串形式编码,因此数组参数会以 URL 编码后的 JSON 数组出现。解析后即可得到真正的 JS 数组,配合.default([])保证缺省时仍为数组而非undefined

嵌套对象参数

const schema = z.object({ filters: fallback( z.object({ status: z.enum(['active', 'inactive']).optional(), tags: z.string().array().optional(), priceRange: z .object({ min: z.number().min(0), max: z.number().min(0), }) .optional(), }), {}, ).default({}), })

嵌套对象同样以 JSON 形式序列化。内层字段按需设为可选,外层用fallback(..., {}).default({})保证读取时filters至少是一个空对象,避免对未设置字段的属性访问报错。

枚举与约束

const schema = z.object({ sort: fallback(z.enum(['newest', 'oldest', 'price']), 'newest').default( 'newest', ), page: fallback(z.number().int().min(1).max(1000), 1).default(1), limit: fallback(z.number().int().min(10).max(100), 20).default(20), })

z.enum限定取值白名单;z.number().int().min(...).max(...)可以对数值施加整数与范围约束。结合fallback,即使 URL 中出现越界或非法值,也会被静默回退为合法默认值。

可辨识联合(Discriminated Union)

如果只是"基础搜索 + 高级搜索字段共存"的扁平结构,可以让高级字段全部可选:

const schema = z.object({ searchType: fallback(z.enum(['basic', 'advanced']), 'basic').default('basic'), query: fallback(z.string(), '').default(''), // Advanced-only fields are optional category: z.string().optional(), minPrice: z.number().optional(), maxPrice: z.number().optional(), })

若需要真正的可辨识联合校验(不同searchType对应严格不同的字段集),则使用z.discriminatedUnion

const basicSearch = z.object({ searchType: z.literal('basic'), query: z.string(), }) const advancedSearch = z.object({ searchType: z.literal('advanced'), query: z.string(), category: z.string(), minPrice: z.number(), maxPrice: z.number(), }) const schema = z.discriminatedUnion('searchType', [basicSearch, advancedSearch]) export const Route = createFileRoute('/search')({ validateSearch: zodValidator(schema), })

此时校验器会根据searchType的值精确匹配对应的分支 schema,不符合任何分支的输入会校验失败,类型系统也能在组件中根据判别字段收窄联合类型。

输入转换(String to Number)

URL 里的参数本质上是字符串,路由层会先做 JSON 解析再交给校验器。当涉及转换(transform)时,需要理解inputoutput类型的区别:

const schema = z.object({ page: fallback(z.number(), 1).default(1), filter: fallback(z.string(), '').default(''), }) export const Route = createFileRoute('/items')({ // Default: input type used for navigation, output type used for reading validateSearch: zodValidator(schema), // Advanced: swap input/output inference // validateSearch: zodValidator({ schema, input: 'output', output: 'input' }), })

zodValidator支持传入选项对象来交换 input/output 的推导(见 packages/zod-adapter/src/index.ts):当input: 'output'时,导航侧传入值的类型取 schema 的_output;当output: 'input'时,读取侧类型取_input。默认行为是"导航用输入类型、读取用输出类型",绝大多数场景无需改动。

Schema 组合

把常用片段抽成可复用的 schema,再通过.extend()/.merge()组装到具体路由上:

const paginationSchema = z.object({ page: fallback(z.number().int().positive(), 1).default(1), limit: fallback(z.number().int().min(1).max(100), 20).default(20), }) const sortSchema = z.object({ sortBy: z.enum(['name', 'date', 'relevance']).optional(), sortOrder: z.enum(['asc', 'desc']).optional(), }) // Compose for specific routes const productSearchSchema = paginationSchema.extend({ category: z.string().optional(), inStock: fallback(z.boolean(), true).default(true), }) const userSearchSchema = paginationSchema.merge(sortSchema).extend({ role: z.enum(['admin', 'user']).optional(), })

分页、排序这类跨路由共享的参数,非常适合抽成组合单元,避免每个路由重复声明。适配器的测试用例可参考 packages/zod-adapter/tests。

使用 Valibot:Standard Schema 直连

Valibot 1.0+原生实现了 Standard Schema,因此无需任何适配器包装,把 schema 直接传给validateSearch即可;@tanstack/valibot-adapter仅在需要显式控制 input/output 类型时才可选使用。

npm install valibot
import { createFileRoute } from '@tanstack/react-router' import * as v from 'valibot' const productSearchSchema = v.object({ page: v.optional(v.fallback(v.number(), 1), 1), filter: v.optional(v.fallback(v.string(), ''), ''), sort: v.optional( v.fallback(v.picklist(['newest', 'oldest', 'price']), 'newest'), 'newest', ), }) export const Route = createFileRoute('/products')({ // Pass schema directly — Standard Schema compliant validateSearch: productSearchSchema, component: ProductsPage, }) function ProductsPage() { const { page, filter, sort } = Route.useSearch() return <div>Page {page}</div> }

由于 Valibot 直接实现了'~standard'协议(对应 packages/router-core/src/validators.ts 中的StandardSchemaValidator形态),router 可以直接识别并解出input/output类型,因此组件里Route.useSearch()自动获得精确的字段类型。

Valibot 带约束

import * as v from 'valibot' const schema = v.object({ page: v.optional( v.fallback(v.pipe(v.number(), v.integer(), v.minValue(1)), 1), 1, ), query: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(100))), tags: v.optional(v.fallback(v.array(v.string()), []), []), })

v.pipe()v.integer()v.minValue()v.minLength()等共同构成约束链;v.optional(schema, 默认值)的双参形式在缺省时直接补齐默认值。

使用适配器(备选方案)

仅当需要显式控制 input/output 类型时才使用适配器:

import { valibotValidator } from '@tanstack/valibot-adapter' import * as v from 'valibot' const schema = v.object({ page: v.optional(v.fallback(v.number(), 1), 1), }) export const Route = createFileRoute('/items')({ validateSearch: valibotValidator(schema), })

valibotValidator的实现位于 packages/valibot-adapter/src/index.ts,其parse直接调用 valibot 的parse(options, input)types.input/types.output通过 valibot 的InferInput/InferOutput泛型推导(适配器代码中显式置为null,由类型层面推导)。

使用 ArkType:Standard Schema 直连

ArkType 2.0-rc+同样实现了 Standard Schema,schema 可以直接传给validateSearch@tanstack/arktype-adapter仅为需要显式 input/output 类型控制的可选方案。

npm install arktype
import { createFileRoute } from '@tanstack/react-router' import { type } from 'arktype' const productSearchSchema = type({ page: 'number = 1', filter: 'string = ""', sort: '"newest" | "oldest" | "price" = "newest"', }) export const Route = createFileRoute('/products')({ // Pass directly — Standard Schema compliant validateSearch: productSearchSchema, component: ProductsPage, }) function ProductsPage() { const { page, filter, sort } = Route.useSearch() return <div>Page {page}</div> }

ArkType 用字符串语法描述类型与默认值:'number = 1'表示类型为 number、默认值 1,'"newest" | "oldest" | "price" = "newest"'表示枚举与默认值。

ArkType 带约束

import { type } from 'arktype' const searchSchema = type({ 'query?': 'string>0&<=100', page: 'number>0 = 1', 'sortBy?': "'name'|'date'|'relevance'", 'filters?': 'string[]', })

?后缀表示可选字段;'string>0&<=100'表示长度大于 0 且不超过 100 的字符串;'number>0'表示大于 0 的数字;'string[]'表示字符串数组。若使用适配器,其底层通过options.assert(input)完成校验,并借助inferIn/infer推导输入输出类型(见 packages/arktype-adapter/src/index.ts)。

手动校验函数:零依赖的完全控制

如果不引入任何校验库,可以直接给validateSearch传一个纯函数。该函数接收经过 JSON 解析但尚未校验的原始搜索参数Record<string, unknown>),并返回强类型结果:

import { createFileRoute } from '@tanstack/react-router' type ProductSearch = { page: number filter: string sort: 'newest' | 'oldest' | 'price' } export const Route = createFileRoute('/products')({ validateSearch: (search: Record<string, unknown>): ProductSearch => ({ page: Number(search?.page ?? 1), filter: (search.filter as string) || '', sort: search.sort === 'newest' || search.sort === 'oldest' || search.sort === 'price' ? search.sort : 'newest', }), component: ProductsPage, }) function ProductsPage() { const { page, filter, sort } = Route.useSearch() return <div>Page {page}</div> }

这种形态对应Validator联合类型中的ValidatorFn:返回值直接决定useSearch()的读取类型,参数类型(Record<string, unknown>)则决定导航侧允许传入的输入。所有解析、兜底、白名单判断都需要自己写,但也因此获得零依赖、逻辑完全透明的灵活性。

手动校验抛错:触发 errorComponent

如果validateSearch函数内部throw异常,路由会渲染errorComponent而不是崩溃或静默放行:

export const Route = createFileRoute('/products')({ validateSearch: (search: Record<string, unknown>) => { const page = Number(search.page) if (isNaN(page) || page < 1) { throw new Error('Invalid page number') } return { page } }, errorComponent: ({ error }) => ( <div> Bad search params:{' '} {error instanceof Error ? error.message : String(error)} </div> ), })

这是"严格校验"模式:对非法输入直接拒绝渲染,并通过errorComponent向用户呈现可读的错误信息。适合对参数合法性要求严格的场景(例如页面号越界、必填参数缺失)。

模式:带parse方法的对象

任何"拥有.parse()方法的对象"都可以直接作为validateSearch。这本质上是ValidatorObj形态(见 packages/router-core/src/validators.ts),让你可以用类、工厂函数或自定义对象封装校验逻辑:

const mySchema = { parse: (input: Record<string, unknown>) => ({ page: Number(input.page ?? 1), query: String(input.query ?? ''), }), } export const Route = createFileRoute('/search')({ validateSearch: mySchema, })

router-core 会通过ResolveValidatorInputFnparse的签名中推导输入类型、从返回值推导输出类型(packages/router-core/src/validators.ts)。这意味着只要你提供一个带parse的对象,类型推导就自动生效——这也是各类适配器背后通用的最小协议。

搜索参数中的日期处理

永远不要把Date对象放进搜索参数。搜索参数最终要序列化进 URL(以 JSON 形式),Date无法可靠往返。正确做法是统一使用 ISO 字符串存储,在组件内按需转回Date

const schema = z.object({ // Store as string, parse in component if needed startDate: z.string().optional(), endDate: z.string().optional(), }) // In component: function DateFilter() { const { startDate } = Route.useSearch() const date = startDate ? new Date(startDate) : null return <div>{date?.toLocaleDateString()}</div> } // When navigating: ;<Link search={(prev) => ({ ...prev, startDate: new Date().toISOString() })}> Set Start Date </Link>

这样 URL 中的日期是稳定、可比较、可复制的 ISO 字符串;导航时用new Date().toISOString()写入,读取时再按需new Date(startDate)解析。顺带一提,Linksearch属性支持函数式更新((prev) => ({ ...prev, ... })),可以在不丢失其他参数的前提下局部修改日期字段。

小结:如何选择校验方案

方案接入方式适用场景
Zod +@tanstack/zod-adapterzodValidator(schema),默认值用fallback()已在使用 Zod 的团队,需要丰富生态与社区方案
Valibot 1.0+schema 直传validateSearch追求体积小、依赖轻,且希望免适配器直连
ArkType 2.0-rc+type直传validateSearch喜欢字符串语法描述类型、希望类型定义极致紧凑
手动函数(search) => result零依赖、逻辑透明,或需要自定义严格校验(配合抛错)
parse方法对象{ parse(input) {...} }已有自定义校验类/工具,希望直接复用

无论选择哪种方案,validateSearch的产出都是一致的:导航时接受宽松输入(input 类型),读取时得到强类型且带默认值保障的输出(output 类型)。想深入源码细节的读者,可以继续研读 packages/router-core/src/validators.ts(校验器类型体系与 Standard Schema 协议)、packages/router-core/src/route.ts(validateSearch路由选项),以及 packages/zod-adapter/src/index.ts、packages/valibot-adapter/src/index.ts、packages/arktype-adapter/src/index.ts 三个适配器的具体实现;各适配器的测试用例位于对应包的 tests 目录,可作为行为验证的参考。

【免费下载链接】router🤖 A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询