文章

高级类型挑战深度解析

Promise.all 的类型推导、柯里化函数的类型推断、对象路径类型提取三道类型挑战,分别考察变长元组映射、递归条件类型和模板字面量类型。 掌握它们意味着从会用 TypeScript 进阶到能设计类型系统,是中高级前端面试的分水岭。

高级类型挑战深度解析

一句话概括

Promise.all 的类型推导、柯里化函数的类型推断、对象路径的类型提取——三个 TypeScript 类型挑战,分别考察变长元组映射、递归条件类型、模板字面量类型,掌握了它们你就从”会用 TypeScript”进阶到”能设计类型系统”。

核心知识点

1. Promise.all 的类型:变长元组的 map

Promise.all 接受 [Promise<A>, Promise<B>, Promise<C>],返回 Promise<[A, B, C]>——核心是把元组中的每个元素解包:

1
2
3
4
5
6
7
8
9
10
11
// 关键:在映射类型中对元组使用 infer
type AwaitedAll<T extends readonly unknown[]> = 
  T extends readonly [infer First, ...infer Rest]
    ? [Awaited<First>, ...AwaitedAll<Rest>]
    : [];

declare function myAll<T extends readonly unknown[]>(promises: T): Promise<AwaitedAll<T>>;

// 验证
const [a, b] = await myAll([Promise.resolve(42), Promise.resolve('hello')] as const);
// a: number, b: string ✅

关键细节:

  • 必须用 as const 或精确类型,否则 [Promise.resolve(42), ...] 被推断为 Promise<number>[](数组而非元组)
  • Awaited 递归解包 Promise<Promise<T>> 也解到 T

2. 柯里化函数的类型推导:递归条件类型

把 (a: number, b: string, c: boolean) => void 的柯里化版本类型推导为 (a: number) => (b: string) => (c: boolean) => void:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Curried<A extends any[], R> =
  A extends [infer First, ...infer Rest]
    ? Rest extends []
      ? (arg: First) => R              // 最后一个参数:直接返回 R
      : (arg: First) => Curried<Rest, R> // 还有剩余参数:递归
    : () => R;                          // 空参数列表

// 配合函数实现
function curry<T extends (...args: any[]) => any>(fn: T): Curried<Parameters<T>, ReturnType<T>> {
  return function curried(...args: any[]): any {
    if (args.length >= fn.length) return fn(...args);
    return (...more: any[]) => curried(...args, ...more);
  } as any;
}

// 使用
const add = (a: number, b: number, c: number) => a + b + c;
const curriedAdd = curry(add);
curriedAdd(1)(2)(3); // 6,类型完全正确 ✅

3. 路径提取类型:模板字面量 + 递归

从 { user: { profile: { name: string } } } 中提取类型安全的路径 'user.profile.name':

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 提取所有可能的点分隔路径
type ObjectPaths<T> = T extends object ? {
  [K in keyof T & string]:
    | K
    | (T[K] extends object ? `${K}.${ObjectPaths<T[K]>}` : never)
}[keyof T & string] : never;

// 根据路径提取值类型
type PathValue<T, P extends string> =
  P extends `${infer K}.${infer Rest}`
    ? K extends keyof T
      ? PathValue<T[K], Rest>
      : never
    : P extends keyof T
      ? T[P]
      : never;

// 使用
type User = { user: { name: string; tags: string[]; meta: { age: number } } };
type Paths = ObjectPaths<User>; // 'user' | 'user.name' | 'user.tags' | 'user.meta' | 'user.meta.age'
type NameVal = PathValue<User, 'user.meta.age'>; // number

这就是 lodash.get(obj, 'a.b.c') 的类型安全版本——lodash 的 get 函数返回 any,而我们的 PathValue 精确返回对应类型。

4. 三个挑战的共同模式

1
2
3
4
5
6
7
8
// 模式一:递归 + 变长元组 → Promise.all / 柯里化
// 模式二:模板字面量分割 + 递归 → 路径提取
// 模式三:infer 解构 + 条件类型 → 这三者全用到

// 统一框架:
// type Challenge<T> =
//   T extends 终止条件 ? 结果
//     : 递归步骤<Challenge<缩小后的T>>;

5. 实战:类型安全的 lodash.get

结合三个挑战的能力,手写一个类型安全的 get:

1
2
3
4
5
6
7
8
9
10
11
12
13
type DeepGet<T, P extends string> =
  P extends `${infer K}.${infer Rest}` 
    ? K extends keyof T ? DeepGet<T[K], Rest> : never
    : P extends keyof T ? T[P] : never;

function deepGet<T, P extends string>(obj: T, path: P): DeepGet<T, P> {
  return path.split('.').reduce((acc: any, key) => acc?.[key], obj);
}

const obj = { user: { name: 'Alice', meta: { age: 30 } } };
const name = deepGet(obj, 'user.name');    // string ✅
const age = deepGet(obj, 'user.meta.age'); // number ✅
// deepGet(obj, 'user.xxx');              // ❌ 编译报错:路径不存在

其实你每天都在用

  1. Promise.all 的类型推导——你在 IDE 中写 const [user, posts] = await Promise.all([fetchUser(), fetchPosts()]) 时,TypeScript 自动推导 user 和 posts 的类型而不需要手动标注。这就是 AwaitedAll 在实际库中的实现。
  2. zustand 的 selector 路径类型——useStore(state => state.user.profile.name) 的类型推导,背后就是类型级别的路径钻取。一些高级封装(如 zustand-x)还支持字符串路径选择器 useStore('user.profile.name'),用到的正是上面的 DeepGet 模式。
  3. React Router 的路径参数——/user/:id/posts/:postId → { id: string; postId: string },React Router v6 的类型推导就是把 URL 模板字符串解析为参数对象类型,用到了模板字面量类型分割。
  4. Zod 的 .refine() 链式调用——每次 .refine() 返回新的 Zod 类型,TS 能追踪每一步的类型变化。这种链式类型转换和柯里化类型的递归推导是同一套思维。
  5. tRPC 的路径类型推导——trpc.user.byId.useQuery({ id: 1 }) 中,user.byId 这个路径本身就是类型安全的——如果后端没有这个路由,TS 直接报错。这是 ObjectPaths 在真实生产环境中的应用。

常见误解(FAQ)

  • ❌ 误区:「类型挑战是花拳绣腿,工作用不上」 真相:DeepGet 让 lodash.get 有类型安全;AwaitedAll 是 Promise.all 类型推导的核心;路径提取在路由、表单、API 类型中有大量应用。类型挑战训练的是”类型层面的算法思维”——就像学数据结构和算法不是为了在工作中手写快排,而是为了理解性能瓶颈。

  • ❌ 误区:「infer 只能用在条件类型中,没什么灵活性」 真相:infer 配合分布式条件类型和递归,灵活度不输运行时的解构赋值。T extends ${infer A}.${infer B}`` 就像 'a.b'.split('.') 的类型版本。

  • ❌ 误区:「柯里化类型推导需要 function overload 写 N 个重载」 真相:用递归条件类型只需要一个泛型:Curried<A, R>。重载方式需要为每个参数数量写一个签名(fn1, fn2, fn3…),泛型递归版本一行搞定,且支持任意参数数量。

  • ❌ 误区:「模板字面量类型只能做简单的字符串拼接」 真相:配合 infer 和递归,模板字面量可以做字符串分割、路径匹配、URL 解析——${infer K}.${infer Rest} 就是类型版的 .split('.'),威力远超直觉。

一句话总结

三个类型挑战指向同一个核心能力:把运行时的算法思维翻译成类型运算——Promise.all 是类型版的 map,柯里化是类型版的递归函数签名,路径提取是类型版的 split + reduce——当你学会在类型层面”写代码”,TypeScript 就从工具变成了你的超能力。

本文由作者按照 CC BY 4.0 进行授权