为什么我们需要类型安全的路由?
在开发大型前端应用时,路由系统是核心基础设施之一。传统的路由定义通常依赖于字符串路径,例如 /users/:id。这种方式虽然灵活,但存在一个显著问题:参数类型和路径匹配完全依赖运行时检查。如果我们在代码中写错了参数名,或者传递了错误类型的参数,TypeScript 无法在编译时发现这些错误。
// 传统方式:运行时才能发现错误
router.push('/users/123'); // 看起来没问题
router.push('/users/abc'); // 但 id 应该是数字,这里不会报错
类型安全的路由系统能够在编译阶段就捕获这类问题。今天,我们将从 TypeScript 的条件类型出发,一步步构建一个能够自动推断路径参数、并强制参数类型正确的路由系统。这不仅是“类型体操”的绝佳练习,也能真正提升项目的健壮性。
一、条件类型与模板字面量类型回顾
要实现类型安全的路由,我们需要两个核心工具:条件类型和模板字面量类型。
条件类型的基本形式是 T extends U ? X : Y。当类型 T 可以赋值给 U 时,结果类型为 X,否则为 Y。它常用于类型分发和过滤。
模板字面量类型允许我们在类型层面拼接字符串,例如 type Path = `/users/${string}`。结合 infer 关键字,我们可以从字符串中提取动态部分。
// 从 `/users/:id` 中提取 `id`
type ExtractParam<T extends string> =
T extends `${string}:${infer Param}` ? Param : never;
type P = ExtractParam<'/users/:id'>; // 'id'
上面的 ExtractParam 只处理了单个参数。如果路径中有多个参数,比如 /users/:userId/posts/:postId,我们需要递归地提取所有参数。
二、递归提取路径参数
要提取所有参数,我们可以利用条件类型的递归特性。思路是:每次匹配到 :param 后,将剩余部分继续递归,直到没有 : 为止。
type ExtractParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<`/${Rest}`>
: T extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractParams<'/users/:userId/posts/:postId'>;
// 'userId' | 'postId'
注意第二个分支中,我们使用 /${Rest} 重新拼接,是为了保证递归时路径格式一致。这个类型已经能处理大多数常见路由了。
但还有一个问题:参数的类型应该是什么?默认情况下,从 URL 中提取的参数都是字符串。然而,我们可能希望 :id 是数字,或者 :slug 是特定字符串。这就需要引入参数类型映射。
三、构建类型安全的参数对象
假设我们有一个路由配置对象,为每个路径指定参数类型:
interface RouteConfig {
'/users/:userId': { userId: number };
'/posts/:slug': { slug: string };
'/search/:query': { query: string };
}
现在,我们希望写一个函数 navigate(path, params),其中 path 必须是 RouteConfig 的键,params 必须匹配该路径对应的参数类型。如果路径中包含参数,params 就是必需的;如果没有参数,params 可以省略。
首先,我们需要一个类型工具,从路径字符串中提取参数名,然后映射到配置中的类型。但更直接的方式是:直接使用配置对象中定义的类型。
type RoutePath = keyof RouteConfig;
function navigate<T extends RoutePath>(
path: T,
...args: RouteConfig[T] extends Record<string, never>
? []
: [params: RouteConfig[T]]
) {
// 实际路由跳转逻辑
}
这里我们使用了条件类型来判断 RouteConfig[T] 是否为空对象。如果是空对象(无参数),则 args 为空数组;否则要求传入 params。但 Record<string, never> 可能不够精确,我们可以用 keyof RouteConfig[T] extends never 来判断。
type IsEmptyObject<T> = keyof T extends never ? true : false;
function navigate<T extends RoutePath>(
path: T,
...args: IsEmptyObject<RouteConfig[T]> extends true
? []
: [params: RouteConfig[T]]
) {
console.log(`Navigating to ${path}`, args[0]);
}
navigate('/users/:userId', { userId: 123 }); // ✅
navigate('/users/:userId', { userId: 'abc' }); // ❌ 类型错误
navigate('/posts/:slug', { slug: 'hello' }); // ✅
这样,我们就实现了一个基本的类型安全路由。但实际路由路径可能包含查询参数,或者我们需要从路径字符串自动推断参数类型,而不是手动维护 RouteConfig。后者更符合“类型体操”的精神。
四、从路径字符串自动推断参数类型
如果我们不想手动定义 RouteConfig,而是希望从路径字符串本身推断出参数类型,该怎么做?一个常见的约定是:参数名以 number 结尾表示数字,以 boolean 结尾表示布尔值,其他为字符串。例如 /users/:userId 中的 userId 以 Id 结尾,可以推断为数字。
我们可以写一个类型工具 ParamType<T>,根据参数名决定类型:
type ParamType<T extends string> =
T extends `${string}Id` ? number :
T extends `${string}Count` ? number :
string;
type ParamsToObject<T extends string> = {
[K in ExtractParams<T>]: ParamType<K>;
};
type Obj = ParamsToObject<'/users/:userId/posts/:postId'>;
// { userId: number; postId: number }
现在,我们可以创建一个更通用的 navigate 函数,它接受任意路径字符串,并自动推断参数类型:
function navigate<T extends string>(
path: T,
...args: keyof ParamsToObject<T> extends never
? []
: [params: ParamsToObject<T>]
) {
// 实现略
}
navigate('/users/:userId', { userId: 123 }); // ✅
navigate('/users/:userId', { userId: 'abc' }); // ❌ 类型错误
navigate('/about'); // ✅ 无参数
这个方案非常优雅,但它有一个前提:路径字符串必须是字面量类型。如果路径来自变量,TypeScript 会将其推断为 string,从而丢失所有类型信息。因此,在实际项目中,我们通常会将路径定义为常量,或者使用 as const 断言。
五、处理可选参数与通配符
真实的路由系统还需要支持可选参数(如 /users/:id?)和通配符(如 /files/*)。我们可以扩展 ExtractParams 来识别 ? 和 *。
对于可选参数,参数名后跟 ?,在生成参数对象时,该属性应标记为可选:
type ExtractParamsWithOptional<T extends string> =
T extends `${string}:${infer Param}?/${infer Rest}`
? { [K in Param]?: string } & ExtractParamsWithOptional<`/${Rest}`>
: T extends `${string}:${infer Param}?`
? { [K in Param]?: string }
: T extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param]: string } & ExtractParamsWithOptional<`/${Rest}`>
: T extends `${string}:${infer Param}`
? { [K in Param]: string }
: {};
这个类型会生成一个交叉类型,例如 { id?: string } & { name: string }。虽然交叉类型在大多数情况下工作良好,但有时会导致属性合并问题。更健壮的方式是使用递归构建一个单一对象类型,但这超出了入门范围。
通配符 * 通常不需要参数,但可以匹配任意路径。我们可以简单地在提取参数时忽略它。
六、总结与进阶方向
通过本文,我们利用条件类型、模板字面量类型和递归,构建了一个能够自动推断路径参数及其类型的路由系统。核心步骤包括:
- 提取参数名:使用
infer和递归从路径字符串中提取所有:param。 - 推断参数类型:根据参数名约定(如
Id结尾为数字)映射到 TypeScript 类型。 - 生成参数对象:将参数名映射为对象类型,并处理空参数情况。
- 约束函数签名:使用条件类型和剩余参数,强制调用者传入正确的参数。
当然,这只是一个起点。在生产环境中,你可能还需要处理:
- 查询参数:使用
URLSearchParams类型或自定义解析。 - 嵌套路由:递归组合路径片段。
- 路由守卫:结合类型安全的中间件。
- 与框架集成:如 React Router、Vue Router 的类型增强。
类型体操的魅力在于,它让编译时检查覆盖到运行时逻辑的边界。虽然复杂的类型可能降低代码可读性,但在路由这种高频使用的场景中,一次投入能换来长期的开发体验提升。希望本文能帮助你打开类型编程的大门,在下一个项目中尝试用类型守护你的路由。
未经允许不得转载:任鹏个人博客 » TypeScript 类型体操入门:用条件类型实现一个类型安全的路由系统


朋友圈点赞图在线生成源码