TypeScript 类型体操入门:用条件类型实现一个类型安全的路由系统

为什么我们需要类型安全的路由?

在开发大型前端应用时,路由系统是核心基础设施之一。传统的路由定义通常依赖于字符串路径,例如 /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 中的 userIdId 结尾,可以推断为数字。

我们可以写一个类型工具 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 }。虽然交叉类型在大多数情况下工作良好,但有时会导致属性合并问题。更健壮的方式是使用递归构建一个单一对象类型,但这超出了入门范围。

通配符 * 通常不需要参数,但可以匹配任意路径。我们可以简单地在提取参数时忽略它。

六、总结与进阶方向

通过本文,我们利用条件类型、模板字面量类型和递归,构建了一个能够自动推断路径参数及其类型的路由系统。核心步骤包括:

  1. 提取参数名:使用 infer 和递归从路径字符串中提取所有 :param
  2. 推断参数类型:根据参数名约定(如 Id 结尾为数字)映射到 TypeScript 类型。
  3. 生成参数对象:将参数名映射为对象类型,并处理空参数情况。
  4. 约束函数签名:使用条件类型和剩余参数,强制调用者传入正确的参数。

当然,这只是一个起点。在生产环境中,你可能还需要处理:

  • 查询参数:使用 URLSearchParams 类型或自定义解析。
  • 嵌套路由:递归组合路径片段。
  • 路由守卫:结合类型安全的中间件。
  • 与框架集成:如 React Router、Vue Router 的类型增强。

类型体操的魅力在于,它让编译时检查覆盖到运行时逻辑的边界。虽然复杂的类型可能降低代码可读性,但在路由这种高频使用的场景中,一次投入能换来长期的开发体验提升。希望本文能帮助你打开类型编程的大门,在下一个项目中尝试用类型守护你的路由。

未经允许不得转载:任鹏个人博客 » TypeScript 类型体操入门:用条件类型实现一个类型安全的路由系统

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏