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>这里的DefaultValidator即Validator<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'键暴露types与validate(见 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 的_input、output类型取_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)时,需要理解input与output类型的区别:
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 valibotimport { 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 arktypeimport { 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 会通过ResolveValidatorInputFn从parse的签名中推导输入类型、从返回值推导输出类型(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)解析。顺带一提,Link的search属性支持函数式更新((prev) => ({ ...prev, ... })),可以在不丢失其他参数的前提下局部修改日期字段。
小结:如何选择校验方案
| 方案 | 接入方式 | 适用场景 |
|---|---|---|
Zod +@tanstack/zod-adapter | zodValidator(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),仅供参考