你一定写过这样的代码:
ts
router.push('/user/' + userId + '/posts/' + postId)
然后某天,后端把 /user/:id/posts/:postId 改成了 /users/:userId/articles/:articleId,你的页面白屏了。运行时才炸,线上才发现------经典。
问题的本质不是你粗心,而是路由参数从来没有被类型系统管过 。路径是 string,参数也是 string,TypeScript 在这件事上跟 JavaScript 一样瞎。
今天聊的就是:怎么让 TypeScript 在编译期就帮你把路由参数校验了,写错直接红线,不用等到运行时。
从一个真实需求说起
假设你在做一个中后台系统,路由表长这样:
ts
const routes = {
home: '/',
userDetail: '/user/:userId',
postDetail: '/user/:userId/posts/:postId',
settings: '/settings/:tab',
} as const
你希望调用跳转方法时,TypeScript 能自动推断出需要哪些参数:
ts
navigate('postDetail', { userId: '123', postId: '456' }) // ✅ 自动提示需要 userId 和 postId
navigate('postDetail', { userId: '123' }) // ❌ 编译报错:缺少 postId
navigate('postDetail', { userId: '123', postId: '456', extra: 'oops' }) // ❌ 编译报错:多了不存在的参数
这不是幻想,Template Literal Types 能做到。
Template Literal Types 速览
TypeScript 4.1 引入了模板字面量类型------把字符串操作搬到了类型层面。
ts
type Greeting = `Hello, ${string}`
const a: Greeting = 'Hello, world' // ✅ 匹配
const b: Greeting = 'Hi, world' // ❌ 不匹配
看起来像个玩具?但配合 infer 关键字,它就变成了编译期的正则提取器。
核心机制:从路径字符串中提取参数
先看最关键的一步------把 /user/:userId/posts/:postId 里的 :userId 和 :postId 提取出来。
ts
// 递归拆解路径,遇到 :xxx 就提取参数名
type ExtractParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}` // 匹配 :param/rest 的模式
? Param | ExtractParams<Rest>
: T extends `${string}:${infer Param}` // 匹配末尾的 :param(后面没有 / 了)
? Param
: never // 没有参数,返回 never
// 测试
type Test1 = ExtractParams<'/user/:userId/posts/:postId'>
// ^? 'userId' | 'postId' ✅
type Test2 = ExtractParams<'/settings/:tab'>
// ^? 'tab' ✅
type Test3 = ExtractParams<'/'>
// ^? never ✅ 首页没参数,合理
思路跟写递归函数一模一样:拆第一段,处理剩余部分,直到没得拆。TypeScript 的类型系统本质上是一门函数式语言,infer 就是它的模式匹配。
把提取结果变成参数对象
光提取出 'userId' | 'postId' 还不够,得变成 { userId: string; postId: string }。
ts
// 联合类型 → 对象类型,每个参数都是必填的 string
type ParamsObject<T extends string> = {
[K in ExtractParams<T>]: string
}
type Result = ParamsObject<'/user/:userId/posts/:postId'>
// ^? { userId: string; postId: string } ✅
一行搞定。映射类型(Mapped Types)在这里是最佳搭档。
组装:类型安全的 navigate 函数
把所有零件拼起来:
ts
const routes = {
home: '/',
userDetail: '/user/:userId',
postDetail: '/user/:userId/posts/:postId',
settings: '/settings/:tab',
} as const // as const 是关键------锁定字面量类型
type Routes = typeof routes
type RouteName = keyof Routes
// 没参数 → 不需要第二个参数;有参数 → 必须传
type RouteParams<N extends RouteName> =
ExtractParams<Routes[N]> extends never
? []
: [params: ParamsObject<Routes[N]>]
function navigate<N extends RouteName>(
name: N,
...args: RouteParams<N>
): void {
const pattern = routes[name] as string
const params = (args[0] ?? {}) as Record<string, string>
// 运行时替换 :param 为实际值
const path = pattern.replace(/:(\w+)/g, (_, key) => {
return params[key] ?? `:${key}`
})
console.log('Navigating to:', path)
}
试试效果:
ts
navigate('home') // ✅ 首页不需要参数
navigate('userDetail', { userId: '42' }) // ✅ 自动提示需要 userId
navigate('postDetail', { userId: '42', postId: '7' }) // ✅ 自动提示需要 userId 和 postId
navigate('postDetail', { userId: '42' }) // ❌ 报错:缺少属性 "postId"
navigate('home', { userId: '42' }) // ❌ 报错:home 不需要参数
写错了?编辑器直接红线。不用启动项目,不用打开浏览器,不用等 QA 提 bug。
进阶:支持查询参数
真实项目里,路由不只有路径参数。你可能还想支持查询参数:
ts
// /user/:userId?tab=posts&page=2
navigate('userDetail', {
userId: '42',
query: { tab: 'posts', page: '2' }
})
扩展一下类型:
ts
type NavigateOptions<N extends RouteName> = ExtractParams<Routes[N]> extends never
? { query?: Record<string, string> }
: ParamsObject<Routes[N]> & { query?: Record<string, string> }
// 更进一步,给特定路由定义 query 参数类型
interface RouteQueryMap {
userDetail: { tab?: 'posts' | 'likes' | 'comments' }
postDetail: { highlight?: string }
}
这样连 query 参数都能获得类型提示。
更硬核:编译期路径拼接校验
如果你不喜欢 navigate('name', params) 这种风格,想直接写路径字符串,也行:
ts
// 把路由模式转成合法路径的类型
// '/user/:userId' → `/user/${string}`
type PatternToPath<T extends string> =
T extends `${infer Start}:${infer _Param}/${infer Rest}`
? `${Start}${string}/${PatternToPath<Rest>}`
: T extends `${infer Start}:${infer _Param}`
? `${Start}${string}`
: T
type ValidPaths = PatternToPath<Routes[RouteName]>
function pushPath(path: ValidPaths): void {
// ...
}
pushPath('/user/42/posts/7') // ✅ 合法路径
pushPath('/settings/general') // ✅ 合法路径
pushPath('/user/42/comments') // ❌ 不匹配任何路由模式
注意 :路由很多时,ValidPaths 会变成巨大的联合类型,编译性能会下降。下面会聊到。
该不该上?
收益
| 维度 | 效果 |
|---|---|
| 开发体验 | 参数自动补全,写错即时反馈 |
| 重构安全 | 改路由模式 → 所有调用处自动报错 |
| 文档成本 | 类型即文档,不用翻 wiki 查参数名 |
成本
| 维度 | 代价 |
|---|---|
| 类型复杂度 | 新人看到 ExtractParams 可能当场辞职 |
| 编译性能 | 路由超过 50 条时,递归类型会拖慢 tsc |
| 维护门槛 | 类型出 bug 比逻辑出 bug 更难调试 |
编译期 vs 运行时校验
两者解决的是不同层面的问题:
ts
// 运行时校验:能兜底外部输入,但上线后才发现内部调用的错误
const params = z.object({ userId: z.string() }).parse(route.params)
// 编译期校验:写代码时就知道错了,但管不了动态输入
navigate('userDetail', { userId: 42 }) // ❌ 直接红线
最佳实践是两层都做:编译期管内部调用,运行时管外部输入(URL 栏直接敲的、deeplink 过来的)。
踩坑记录
as const 忘了写
ts
const routes = { home: '/' } // ❌ 类型退化为 { home: string },后面全废
const routes = { home: '/' } as const // ✅ 类型是 { readonly home: '/' }
递归深度限制
TypeScript 对类型递归有深度限制(大约 50 层)。路径嵌套太深会炸:
ts
type OK = ExtractParams<'/a/:b/c/:d/e/:f'> // ✅ 正常
// 20 层嵌套...祝你好运
实际项目中路由很少超过 4 层,这个限制基本碰不到。
动态路由注册
微前端场景下子应用运行时注入路由,编译期管不了。老老实实用运行时校验。
类型报错信息
简单场景还行:
python
Type '{ userId: string; }' is not assignable to type '{ userId: string; postId: string; }'.
Property 'postId' is missing in type '{ userId: string; }'.
类型更复杂时报错会变成天书。可以用 @ts-expect-error 配合注释来改善体验。
与框架的现状
- Next.js App Router:已经内置了部分路由类型推断,但粒度有限
- Vue Router :可以通过
typed-router插件获得类型安全 - React Router v7:开始支持类型安全的路由定义
趋势很明确:路由类型安全正在从"体操爱好者的玩具"变成框架的标配。自己实现一遍才能理解里面的设计取舍。
这套思路的通用性
退一步看,我们做的事情本质上是:把运行时的字符串约束,前移到编译期的类型约束。
不只是路由。任何"字符串里藏着结构"的场景,都可以这么搞:
ts
// SQL 排序校验
type Column = 'id' | 'name' | 'email'
type OrderBy = `${Column} ${'ASC' | 'DESC'}`
const query: OrderBy = 'name ASC' // ✅
const bad: OrderBy = 'name UP' // ❌
ts
// 事件名校验
type EventName = `on${Capitalize<'click' | 'hover' | 'focus'>}`
const e: EventName = 'onClick' // ✅
const f: EventName = 'onBlur' // ❌ Blur 不在联合类型中
通用模型就是:infer 拆字符串 → 递归提取结构 → 映射成类型。零运行时成本。
下次遇到"某个字符串有固定格式,但 TypeScript 只认它是 string"的场景,想想这个套路。大部分类型体操都是换皮。