TypeScript 类型体操:条件类型 + infer 的 10 个实战场景(避坑指南)
"
很多前端在写 TypeScript 时,一旦遇到泛型嵌套,或者看开源库源码时,看到 T extends U ? X : Y 这种三元表达式满天飞,头就开始痛了。如果中间突然再来一个 infer,简直像是在看天书。
其实这就是圈内常说的一个概念 —— 类型体操。
今天咱们不整那些虚的概念,直接讲 10 个实际的场景,让你看懂它们的用法。
核心公式速查
在开始之前,哪怕你记不住别的,请死记硬背这个公式:
type MyType<T> = T extends SomeType<infer R> ? R : never;翻译成人话就是:看 T 长得像不像 SomeType,如果像,就把 SomeType 里的某一部分(命名为 R)推断出来给我用。
一、 拆包:把东西“拿”出来
最基础也是最高频的场景,就是把裹了一层又一层的数据结构拆开。
👉 场景 1:提取 Promise 里的返回值(Awaited)
假如有一个Promise<{ id: number; name: string }>类型,你只需要用里面的{ id: number; name: string },你就可以这样做:
// 原始类型
type UserPromise = Promise<{ id: number; name: string }>;
// 拆包
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type User = UnwrapPromise<UserPromise>;
// 结果:{ id: number; name: string }👉 场景 2:提取数组的元素类型
有时候你拿到的是一个数组类型 User[],但你需要单个元素的类型来定义详情页的 Props。
type ArrayElement<T> = T extends (infer U)[] ? U : never;
type Item = ArrayElement<string[]>; // string👉 场景 3:深层递归拆包(一定要加递归)
对于 Promise<Promise<string>> 怎么拆?上面的 UnwrapPromise 只能拆一层。这时候得学会递归调用。
// 👈 注意后面的递归调用 UnwrapDeep<U>
type UnwrapDeep<T> = T extends Promise<infer U> ? UnwrapDeep<U> : T;
type Result = UnwrapDeep<Promise<Promise<string>>>; // string二、 在函数中的应用
我们在封装第三方库或者高阶组件(HOC)时,经常需要“借用”原函数的参数或返回值类型。
👉 场景 4:获取函数的返回值(ReturnType)
这是官方内置的工具类型,但自己手写一遍才能理解原理。
const getUser = () => ({ name: 'Jack', age: 18 });
// 仔细看 infer 放在哪
type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type User = GetReturnType<typeof getUser>;
// 结果:{ name: string; age: number }👉 场景 5:获取函数的第一个参数
做组件封装时,想把 onChange 的第一个参数透传出去,但不想把整个 ...args 都拿过来。
type FirstArg<T> = T extends (first: infer F, ...args: any[]) => any ? F : never;
type Handler = (e: Event, id: string) => void;
type EventType = FirstArg<Handler>; // Event三、 模板字面量 + infer
TS 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">; // "Neumann"
// 这里的逻辑是:一直匹配直到最后的一个空格,把剩下的扔给 Last👉 场景 7:去掉字符串的前缀(Trim)
Vue 或 React 的 Props 里,有时候会有 on-click 这种命名,转成内部事件名需要去掉 on-。
type RemoveOnPrefix<T> = T extends `on-${infer Rest}` ? Rest : T;
type EventName = RemoveOnPrefix<"on-click">; // "click"👉 场景 8:解析路由参数(高阶玩法)
这是一个很酷的场景:从 URL 字符串 /user/:id/post/:postId 中自动提取出 { id: string, postId: string }。
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'>;
// 结果:{ id: string; postId: string; }四、 必须要懂的“避坑”场景
条件类型有两个最大的坑:分发和死循环。
👉 场景 9:防止联合类型分发
这是很多人懵逼的地方。 当你把 string | number 传给 T extends U 时,TS 默认会把它们拆开分别运算,最后再合并。
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// 结果是:string[] | number[] 👈 (分发了)
// 但我想要的是:(string | number)[]避坑指南: 如果你不想分发,用方括号 [] 把 T 包起来。
// ✅ 修正版
type ToArraySafe<T> = [T] extends any ? T[] : never;
type ResultSafe = ToArraySafe<string | number>; // (string | number)[]👉 场景 10:排除 null 和 undefined(NonNullable 实现原理)
有时候泛型 T 里混入了 null,导致后续逻辑报错。我们可以利用条件类型把它们“过滤”掉。
// 这里的原理是:如果 T 是 null,返回 never(在联合类型中 never 会被自动消灭)
type MyNonNullable<T> = T extends null | undefined ? never : T;
type Clean = MyNonNullable<string | null | undefined>; // string总结
看完这 10 个场景,你可能觉得手痒想去重构项目里的类型了。
但我得泼一盆冷水:类型体操是把双刃剑。
"
如果是写通用工具库,这些技巧将会非常实用,能让使用者的体验丝般顺滑;但在业务代码里,尽量少写复杂的类型体操,否则你的同事会想顺着网线过去打 si 你。
你只需记住:extends 是判断,infer 是声明变量,基本就能看懂这些类型体操了。