许多前端开发者在编写 TypeScript,特别是接触到深层泛型嵌套或阅读开源库源码时,看到类似 T extends U ? X : Y 这样的条件类型四处散落,往往会感到困惑。如果再结合 infer 关键字,理解难度更是急剧上升。
这种对类型系统进行复杂操作和推导的技巧,通常被称为类型体操。本文不空谈概念,而是通过十个具体的实战场景,帮助你透彻理解条件类型与 infer 的协同工作原理。
核心模式速记
在深入场景之前,请务必掌握这个核心模式公式:
type MyType<T> = T extends SomeType<infer R> ? R : never;
你可以将其理解为:检查类型 T 是否匹配 SomeType<某部分> 这种结构。如果匹配,就将其中的“某部分”推断出来并命名为 R 以供使用。
一、 类型拆解:提取嵌套结构中的内部类型
这是最基础且最高频的应用场景,旨在从层层包裹的类型中提取出我们需要的部分。
场景 1:提取 Promise 的返回值类型
当你拥有一个 Promise<X> 类型,但只需要内部的 X 类型时,可以这样操作:
// 原始类型
type UserPromise = Promise<{ id: number; name: string }>;
// 拆解类型
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type User = UnwrapPromise<UserPromise>;
// 结果:User 的类型为 { id: number; name: string }
场景 2:获取数组元素的类型
从一个数组类型(如 User[])中提取其单个元素的类型,常用于定义详情组件的 Props。
type ArrayElement<T> = T extends (infer U)[] ? U : never;
type Item = ArrayElement<string[]>; // Item 的类型为 string
场景 3:深层递归解包 Promise
对于 Promise<Promise<string>> 这类多层嵌套的 Promise,上述 UnwrapPromise 只能解开一层。此时需要引入递归。
// 注意后续的递归调用 UnwrapDeep<U>
type UnwrapDeep<T> = T extends Promise<infer U> ? UnwrapDeep<U> : T;
type Result = UnwrapDeep<Promise<Promise<string>>>; // Result 的类型为 string
二、 应用于函数类型推导
在封装工具函数、高阶组件或第三方库时,经常需要提取原函数的参数类型或返回值类型。
场景 4:获取函数返回值类型(实现 ReturnType)
这是 TypeScript 内置的工具类型,理解其实现原理至关重要。
const getUser = () => ({ name: 'Jack', age: 18 });
// 注意 infer 放置的位置
type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type User = GetReturnType<typeof getUser>;
// 结果:User 的类型为 { name: string; age: number }
场景 5:提取函数的第一个参数类型
在组件封装中,有时只需要透传事件处理函数的第一个参数(如 event 对象)。
type FirstArg<T> = T extends (first: infer F, ...args: any[]) => any ? F : never;
type Handler = (e: Event, id: string) => void;
type EventType = FirstArg<Handler>; // EventType 的类型为 Event
三、 结合模板字面量类型
TypeScript 4.1 引入的模板字面量类型,与 infer 配合后,成为了现代前端框架(如 Next.js、TanStack Router)实现类型安全路由的关键技术。
场景 6:提取字符串最后一部分
处理全名或文件路径,仅保留最后一段。
type GetLastName<T extends string> =
T extends `${infer _FirstWord} ${infer RestOfString}`
// 注意这里的递归调用
? GetLastName<RestOfString>
: T;
type Name = GetLastName<"John Von Neumann">; // Name 的类型为 "Neumann"
场景 7:移除字符串前缀
在处理类似 Vue 或 React 中 onClick 这类 Props 命名,需要将其转换为内部事件名时很有用。
type RemoveOnPrefix<T> = T extends `on-${infer Rest}` ? Rest : T;
type EventName = RemoveOnPrefix<"on-click">; // EventName 的类型为 "click"
场景 8:解析路由参数(高阶应用)
这是一个非常强大的场景:从形如 /user/:id/post/:postId 的路由字符串中,自动推导出参数对象的类型。
type ExtractRouteParams<Path extends string> =
Path extends `${infer _Prefix}/:${infer ParamName}/${infer Rest}`
? ParamName | ExtractRouteParams<Rest>
: Path extends `${infer _Prefix}/:${infer ParamName}`
? ParamName
: never;
type RouteParamsObject<Keys extends string, ValueType = string> = {
[Key in Keys]: ValueType;
};
type GetRouteParamsType<Path extends string> = RouteParamsObject<
ExtractRouteParams<Path>
>;
type RouteParams = GetRouteParamsType<'/users/:id/posts/:postId/edit'>;
// 结果:RouteParams 的类型为 { id: string; postId: string; }
四、 关键注意事项与避坑指南
条件类型有两个需要特别注意的特性:分发(Distributive Conditional Types) 和潜在的递归死循环。
场景 9:如何避免联合类型的自动分发
这是常见的困惑点。当 T 是联合类型(如 string | number)时,T extends U 会将其拆分并分别进行条件判断,最后合并结果。
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// 结果是:string[] | number[] (发生了分发)
// 但我们期望的可能是:(string | number)[]
解决方案:如果希望将联合类型作为一个整体处理,可以用方括号将其包裹。
// ✅ 修正版本,阻止分发
type ToArraySafe<T> = [T] extends [any] ? T[] : never;
type ResultSafe = ToArraySafe<string | number>; // 结果为 (string | number)[]
场景 10:过滤掉 null 和 undefined(实现 NonNullable)
当泛型 T 中可能包含 null 或 undefined 时,可以使用条件类型将其排除。
// 原理:如果 T 是 null 或 undefined,则返回 never。在联合类型中,never 会被自动忽略。
type MyNonNullable<T> = T extends null | undefined ? never : T;
type Clean = MyNonNullable<string | null | undefined>; // Clean 的类型为 string
总结与实践建议
掌握以上十个场景后,你或许已经跃跃欲试,想要重构项目中的类型定义了。但需要清醒地认识到:类型体操是一把双刃剑。
在开发通用工具库、公共组件或框架时,娴熟运用这些技巧能极大提升代码的类型安全性和开发者体验。然而,在复杂的业务逻辑代码中,过度使用晦涩的类型体操可能会显著降低代码的可读性和可维护性,为团队协作带来障碍。
牢记最核心的两点:extends 用于条件判断,infer 用于在条件分支中声明一个类型变量。把握住这个本质,你就能读懂和编写大多数“类型体操”了。在实际开发中,务必在类型表达力的强大与代码的简洁清晰之间找到平衡。