TypeScript 类型体操进阶:用 Template Literal Types 实现编译期路由参数校验

你一定写过这样的代码:

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)在这里是最佳搭档。


把所有零件拼起来:

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"的场景,想想这个套路。大部分类型体操都是换皮。

相关推荐
滕青山2 小时前
文本字符数统计 在线工具核心JS实现
前端·javascript·vue.js
十二7402 小时前
前端缓存踩坑实录:从版本号管理到自动化构建
前端·javascript·nginx
进击的尘埃2 小时前
前端大文件上传全方案:切片、秒传、断点续传与 Worker 并行 Hash 计算实践
javascript
西梯卧客2 小时前
[1-2] 数据类型检测 · typeof、instanceof、toString.call 等方式对比
javascript
wuhen_n2 小时前
响应式探秘:ref vs reactive,我该选谁?
前端·javascript·vue.js
wuhen_n2 小时前
setup 的艺术:如何组织我们的组合式函数?
前端·javascript·vue.js
明月_清风3 小时前
性能级目录同步:IntersectionObserver 实战
前端·javascript
明月_清风3 小时前
告别暴力轮询:深度解锁浏览器“观察者家族”
前端·javascript
摸鱼的春哥4 小时前
Agent教程17:LangChain的持久化和人工干预
前端·javascript·后端