跳转到内容

TypeScript 类型体操:条件类型 + infer 的 10 个实战场景(避坑指南)

"

很多前端在写 TypeScript 时,一旦遇到泛型嵌套,或者看开源库源码时,看到 T extends U ? X : Y 这种三元表达式满天飞,头就开始痛了。如果中间突然再来一个 infer,简直像是在看天书。

其实这就是圈内常说的一个概念 —— 类型体操

今天咱们不整那些虚的概念,直接讲 10 个实际的场景,让你看懂它们的用法。

核心公式速查

在开始之前,哪怕你记不住别的,请死记硬背这个公式:

ts
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 },你就可以这样做:

ts
// 原始类型
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

ts
type ArrayElement<T> = T extends (infer U)[] ? U : never;

type Item = ArrayElement<string[]>; // string

👉 场景 3:深层递归拆包(一定要加递归)

对于 Promise<Promise<string>> 怎么拆?上面的 UnwrapPromise 只能拆一层。这时候得学会递归调用。

ts
// 👈 注意后面的递归调用 UnwrapDeep<U>
type UnwrapDeep<T> = T extends Promise<infer U> ? UnwrapDeep<U> : T;

type Result = UnwrapDeep<Promise<Promise<string>>>; // string

二、 在函数中的应用

我们在封装第三方库或者高阶组件(HOC)时,经常需要“借用”原函数的参数或返回值类型。

👉 场景 4:获取函数的返回值(ReturnType)

这是官方内置的工具类型,但自己手写一遍才能理解原理。

ts
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 都拿过来。

ts
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:提取字符串最后一部分

比如处理文件名或者全限定类名,只想留最后一段。

ts
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-

ts
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 }

ts
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 默认会把它们拆开分别运算,最后再合并。

ts
type ToArray<T> = T extends any ? T[] : never;

type Result = ToArray<string | number>;
// 结果是:string[] | number[]  👈 (分发了)
// 但我想要的是:(string | number)[]

避坑指南: 如果你不想分发,用方括号 []T 包起来。

ts
// ✅ 修正版
type ToArraySafe<T> = [T] extends any ? T[] : never;
type ResultSafe = ToArraySafe<string | number>; // (string | number)[]

👉 场景 10:排除 null 和 undefined(NonNullable 实现原理)

有时候泛型 T 里混入了 null,导致后续逻辑报错。我们可以利用条件类型把它们“过滤”掉。

ts
// 这里的原理是:如果 T 是 null,返回 never(在联合类型中 never 会被自动消灭)
type MyNonNullable<T> = T extends null | undefined ? never : T;

type Clean = MyNonNullable<string | null | undefined>; // string

总结

看完这 10 个场景,你可能觉得手痒想去重构项目里的类型了。

但我得泼一盆冷水:类型体操是把双刃剑

"

如果是写通用工具库,这些技巧将会非常实用,能让使用者的体验丝般顺滑;但在业务代码里,尽量少写复杂的类型体操,否则你的同事会想顺着网线过去打 si 你。

你只需记住:extends 是判断,infer 是声明变量,基本就能看懂这些类型体操了。