TypeScript 三元条件类型在泛型未约束时的分配式推导与 never 短路

面试里只要提到 TypeScript 条件类型,面试官十有八九会追问一句:「为什么 T extends any ? T[] : never 传入联合类型后返回的是联合的数组,而不是数组的联合?」紧接着还会补一刀:「那如果传 never 呢?」这两个问题看着小,实际上分别打在了 distributive conditional types(分配式条件类型) 和

面试里只要提到 TypeScript 条件类型,面试官十有八九会追问一句:「为什么 T extends any ? T[] : never 传入联合类型后返回的是联合的数组,而不是数组的联合?」紧接着还会补一刀:「那如果传 never 呢?」这两个问题看着小,实际上分别打在了 distributive conditional types(分配式条件类型) 和 never 短路 两个核心机制上。答不上来,说明平时只是抄过工具类型(Utility Types),没真正理解类型系统在泛型裸类型参数上的行为。

这篇内容把这两个机制拆开讲清楚:先复现面试官最爱出的题目和错误答案,再讲分配式推导的触发条件(所谓「裸类型参数」到底是什么),然后讲 never 短路为什么会让结果直接变成 never,接着给出控制分配行为的实战手法([T] extends [U] 包裹),最后用一个真实的工具类型推导场景串起来,并附上编译报错和 FAQ。全部代码都可以直接在 TypeScript 5.x 环境下跑 tsc --noEmit 验证。

一、先看题目:三个看似相同的条件类型,结果完全不同

面试题干通常长这样:

type A = string | number extends string ? 'yes' : 'no';
type B<T> = T extends string ? 'yes' : 'no';
type C = B<string | number>;

// A = 'no'
// C = 'yes' | 'no'

很多人第一反应是 A 和 C 应该一致,但实际 A 是 'no',而 C 是 'yes' | 'no'。原因在于:A 里左边的 string | number 是具体的联合类型字面量,条件类型对它做的是整体判断——string | number 并不能被整体赋值给 string,所以走 false 分支。而 B<string | number> 中的 T 是裸类型参数(naked type parameter),条件类型会对联合的每个成员分别求值,再把结果并起来,于是 string 得到 'yes'、number 得到 'no',合并为 'yes' | 'no'。

这就是分配式条件类型(distributive conditional types)的定义:当条件类型作用于一个未加约束包裹的泛型参数 T,且 T 被实例化为联合类型时,TypeScript 会把联合拆开,逐个成员代入条件类型计算,结果以联合形式合并。官方文档里对它的描述是「conditional types over a naked type parameter distribute over union types」。

对照来看,B<never> 的结果是 never 而不是 'yes' | 'no',也不是 'no'。这是第二个考点,后面单独展开。

面试提示:能被问出这道题,说明对方想确认你是否知道「裸类型参数」这个触发条件,而不只是背下「条件类型会分配」这句话。追问往往是:「怎么让它不分配?」

二、分配式推导的触发条件:什么叫「裸类型参数」

分配行为只在满足以下三条时发生,缺一不可:

  • 条件类型的形式是 T extends U ? X : Y;
  • T 是泛型类型参数,而不是具体类型;
  • extends 左侧的 T 是未被包裹的裸类型,即没有被数组、元组、函数、对象属性等类型构造器包住。

一旦 T 被包裹,分配就被关闭。下面四种写法可以作为「关闭分配」的模板:

// 1. 元组包裹:经典写法
type NonDistributiveTuple<T> = [T] extends [string] ? 'yes' : 'no';
type R1 = NonDistributiveTuple<string | number>; // 'no'

// 2. 数组包裹
type NonDistributiveArray<T> = T[] extends string[] ? 'yes' : 'no';
type R2 = NonDistributiveArray<string | number>; // 'no'

// 3. 函数参数包裹
type NonDistributiveFn<T> = ((x: T) => void) extends ((x: string) => void) ? 'yes' : 'no';
type R3 = NonDistributiveFn<string | number>; // 'no'

// 4. 对象属性包裹
type NonDistributiveObj<T> = { v: T } extends { v: string } ? 'yes' : 'no';
type R4 = NonDistributiveObj<string | number>; // 'no'

反过来,如果把 T 直接放在左边,就会分配:

type Distributive<T> = T extends string ? T[] : never;

type D1 = Distributive<string | number>;
// string[] | never  →  string[]
// 注意:number 分支是 never,而 never 在联合里会被吸收,所以只剩 string[]

type D2 = Distributive<string | number | boolean>;
// (string[] | never | never) = string[]

type D3 = Distributive<'a' | 'b' | 1>;
// 'a'[] | 'b'[] | never = 'a'[] | 'b'[]

D1 是最容易答错的:很多人以为返回 string[] | never 就是字面上的联合,其实 never | X 恒等于 X,所以 number 分支直接消失。这与后文 never 短路的语义是一致的,都是 because never 是空联合(the empty union)。

2.1 为什么 T extends any ? T[] : never 是「分配探测器」

社区里常用 T extends any ? ... : never 做「联合类型探测器」,因为 extends any 恒为 true,条件类型唯一的作用就是触发分配。用它可以写出很多工具类型:

// 把联合类型转成由成员组成的联合(其实是恒等,但能观察分配)
type Distribute<T> = T extends any ? T : never;

// 真正的用途:对联合的每个成员分别加工
type Boxed<T> = T extends any ? { value: T } : never;
type B = Boxed<'a' | 1>;
// { value: 'a' } | { value: 1 }

如果写成 type Boxed<T> = { value: T },得到的就是 { value: 'a' | 1 },语义完全不同。这是「分布」与「整体」的差别。

2.2 类型参数被约束后还分配吗

很多人以为给 T 加个 extends 约束就能阻止分配,这是错的。约束只影响可传入的范围,不影响分配行为:

type WithConstraint<T extends string | number> = T extends string ? 'str' : 'num';

type W = WithConstraint<string | number>; // 'str' | 'num',仍然分配

想关闭分配,只能包裹左侧,别无他法。这一点在面试里是高频追问点。

三、never 短路:为什么结果是 never 而不是走 false 分支

never 在类型系统里的语义是「不可能的值」「空联合 / bottom type」。它有两个关键性质:

  • never 可赋值给任何类型(never extends T 恒为 true);
  • 在联合类型中,never 被吸收:T | never = T。

条件类型的分配求值可以形式化地理解为:先把 T 展开成若干成员,对每个成员求 X extends U ? P : Q,最后把所有结果取并集。never 作为空联合,成员个数为零,于是「对零个成员求值再取并集」的结果就是空并集——也就是 never。这就是 never 短路的本质:不是条件为真也不是条件为假,而是根本没有成员可以代入。

type Distribute<T> = T extends string ? 'str' : 'other';

type N1 = Distribute<never>;            // never
type N2 = Distribute<string | never>;   // 'str'   (union 吸收 never,再分配)
type N3 = Distribute<string | number>;  // 'str' | 'other'

对照 N2,能进一步确认:一旦联合里混入 never,它先被 | 吸收掉,分配时根本看不到它,因此不会产生额外的 never 结果。

3.1 用 Exclude / Extract 反推 never 短路

标准库的 Exclude 和 Extract 是最能体现这两个机制的现成例子:

type Exclude<T, U> = T extends U ? never : T;
type Extract<T, U> = T extends U ? T : never;

type E1 = Exclude<'a' | 'b' | 'c', 'b'>; // 'a' | 'c'
type E2 = Extract<'a' | 'b' | 'c', 'b'>; // 'b'
type E3 = Exclude<never, string>;        // never
type E4 = Extract<never, string>;        // never

Exclude 的 true 分支返回值是 never,正是靠「按成员分配 + 被排除的成员映射为 never + never 在并集中被吸收」这三步,才实现了「剔除」。如果 TypeScript 不分配,Exclude 根本没法工作。反过来,Exclude<never, string> 是 never,说明整个条件类型对空联合求值即空——短路了。

3.2 never 短路在实战中的副作用

下面这个模式在 API 层非常常见,也最容易踩坑:

type ApiResult<T> = T extends null | undefined ? void : { data: T };

type R1 = ApiResult<null>;              // void
type R2 = ApiResult<undefined>;         // void
type R3 = ApiResult<null | undefined>;  // void
type R4 = ApiResult<never>;             // never  ← 不是 void

如果某个泛型参数经过 Exclude 或 Extract 后变成 never(例如键联合为空),ApiResult<never> 会静默变成 never,进而把整个泛型链路的类型「传染」成 never,直到某个变量被推断为 never 并报出 Property 'data' does not exist on type 'never'。这类报错在 Redux、tRPC、zod 的类型层里很常见,排查时优先怀疑「某处 keyof 取到了 never」。

3.3 想对 never 单独判定,必须关掉分配

因为 never 会短路,T extends never ? A : B 永远返回 never,拿不到 A 也拿不到 B。要判断一个类型是否是 never,标准写法是用元组包裹:

// 错误写法:永远得到 never
type IsNeverBad<T> = T extends never ? true : false;
type Bad = IsNeverBad<never>; // never,不是 true

// 正确写法:关闭分配后做整体判断
type IsNever<T> = [T] extends [never] ? true : false;
type Good1 = IsNever<never>;   // true
type Good2 = IsNever<string>;  // false
type Good3 = IsNever<string | number>; // false

[never] extends [never] 为真,所以返回 true。这里的关键是元组包裹让 T 不再是裸类型参数,从而不触发分配,也就不会短路。这条几乎可以直接当作面试的标准答案。

四、把分配行为用到正经业务上:一个真实的工具类型推导

下面是一个完整可运行的文件示例,演示如何用分配式条件类型实现「事件负载映射」,同时用元组包裹避免 never 短路导致的静默失败。代码在 TypeScript 5.4 下通过 tsc --noEmit --strict。

type EventMap = {
  click: { x: number; y: number };
  input: { value: string };
  close: undefined;
};

type EventName = keyof EventMap;

// 分配式:对联合的每个事件名分别产出 handler
type Handlers<E extends EventName> = E extends EventName
  ? { type: E; payload: EventMap[E] }
  : never;

type All = Handlers<'click' | 'input'>;
// { type: 'click'; payload: { x: number; y: number } }
// | { type: 'input'; payload: { value: string } }

// 判别联合收敛器:只允许运行时根据 type 分发
function dispatch(e: All): void {
  if (e.type === 'click') {
    const { x, y } = e.payload; // payload 被正确收窄
    console.log(x + y);
  } else {
    console.log(e.payload.value.toUpperCase());
  }
}

dispatch({ type: 'click', payload: { x: 1, y: 2 } });
dispatch({ type: 'input', payload: { value: 'ts' } });

这里 Handlers<'click' | 'input'> 能产出两个分支,靠的就是 E 是裸类型参数。E extends EventName 这个自指约束只是为了让 EventMap[E] 合法,并不影响分配。

再看一个「不要分配」的对比例子——判断一个事件名集合是否完全覆盖了 EventMap:

// 如果分配,每个成员单独判断都成立,无法表达「集合覆盖」语义
type IsFullCoverage<E extends EventName> = E extends EventName ? true : false;
type A = IsFullCoverage<'click'>; // true,显然不是想要的

// 正确写法:用元组包裹,让 extends 做整体比较
type IsFullCoverage<E extends EventName> = [E] extends [EventName] ? true : false;
type B = IsFullCoverage<'click' | 'input' | 'close'>; // true

// 更严格地判定「双向覆盖」,用两个 extends 分别检查子类型方向
type SameSet<A extends string, B extends string> =
  [A] extends [B] ? ([B] extends [A] ? true : false) : false;

type S = SameSet<'a' | 'b', 'b' | 'a'>; // true

这段可以直接回答面试官的「什么时候不希望分配」:当你要表达的是两个类型整体之间的子类型关系(尤其涉及联合的集合覆盖)时,必须用 [T] extends [U] 包裹;当你要对联合的每个成员做同构加工(映射、剔除、提取)时,才需要裸参数触发分配。

五、常见报错与排查清单

下面这些报错和现象在真实项目里能直接对上号,遇到时优先往分配 / never 短路两个方向排查。

5.1 Type 'never' is not assignable to type 'T'

典型堆栈:

src/guards.ts:14:3 - error TS2322: Type 'never' is not assignable to type 'T'.
  'never' is assignable to the constraint of type 'T', but 'T' could be
  instantiated with a different subtype of constraint 'string'.

原因:函数内部把返回值推断成 never(常见于 Extract<T, X> 的结果为空,或 keyof 取到了空对象)。解决方式:把 Extract<T, X> 改成用元组包裹的整体判断先做守卫——先 if ([T] extends [never]) 分支抛出错误,再在剩余分支断言。

5.2 Property 'xxx' does not exist on type 'never'

另一种常见报错:

src/mapper.ts:22:18 - error TS2339: Property 'value' does not exist on type 'never'.

原因:某个判别联合被分配条件类型全部映射成 never,导致 never 传染到具体变量。验证手法:在编辑器里把鼠标悬停到该变量上,如果显示 never,逐步往上找到第一个出现 never 的泛型调用,再确认该调用是不是漏了元组包裹或传了空联合。

5.3 Type instantiation is excessively deep and possibly infinite. (ts2589)

递归条件类型(递归映射对象键)容易撞上深度上限。TypeScript 默认递归深度限制约为 50 层(不同版本略有差异,官方未承诺具体数值),递归工具类型在深层嵌套对象上会报 ts2589。

src/deep.ts:7:3 - error TS2589: Type instantiation is excessively deep and
possibly infinite.

处理办法:给递归加显式的深度计数器并用元组累积,超过阈值就走非递归分支返回任意类型(如 unknown),避免无限展开:

type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9];

type DeepReadonly<T, D extends number = 5> = D extends never
  ? T
  : T extends object
    ? T extends Function
      ? T
      : { readonly [K in keyof T]: DeepReadonly<T[K], Prev[D]> }
    : T;

type Config = { a: { b: { c: { d: { e: { f: number } } } } } };
type Locked = DeepReadonly<Config>;
// 深度超过 5 的层级不再深入,但类型可正常推断,不报 ts2589

注意这里 D extends never 不会触发分配问题,因为 D 是数字字面量联合的成员,但若有人传入 never 会因为短路返回 never——所以不要把 D 的默认值设成 never。

六、面试怎么答:一段可以直接说出口的完整回答

把上面的要点压缩成一条回答链,面试时按顺序说就很完整:

  1. 条件类型 T extends U ? X : Y 中,如果 T 是未包裹的裸类型参数,且实参是联合类型,TypeScript 会按成员分配求值再合并结果,这叫 distributive conditional type;
  2. never 是空联合,成员数为零,分配后得到空联合,所以 T extends U ? X : Y 在 T = never 时返回 never,既不产生 X 也不产生 Y;
  3. 要关闭分配,把左侧包起来:[T] extends [U] 是最常用写法,数组、函数、对象属性包裹同理;
  4. 判断 never 必须用 [T] extends [never] ? true : false,直接写 T extends never 永远是 never;
  5. 标准库 Exclude / Extract 就是分配 + never 吸收的组合,理解它们就理解了这套机制的价值。

如果面试官继续追问「never 短路有没有实际害处」,可以说:它会让泛型链路静默产出 never,进而在下游报 Property 'xxx' does not exist on type 'never' 这种看起来毫无关联的错误;排查手段是顺着 keyof、Extract 一路往上传,找到第一次出现 never 的调用点。

小结

分配式条件类型和 never 短路是同一套求值规则的两个侧面:前者说「联合类型被拆成成员逐个算」,后者说「never 是零个成员,算出来还是零」。理解的关键只有一句话——看 extends 左侧是不是裸类型参数。是裸的,就分配、就短路;被元组、数组、函数、对象属性包起来,就整体比较、不短路。把这个判断标准落实到每一次写工具类型的时候,'str' | 'num' 还是 'no'、never 还是 true,都不再靠试。

FAQ

Q1:给泛型参数加了 extends 约束,还会分配吗?

会。约束只限制传入范围,不改变分配行为。只有把左侧包裹起来才能关闭分配,例如 [T] extends [U]。

Q2:为什么 Exclude<string | number, number> 得到的是 string?

因为 Exclude<T, U> = T extends U ? never : T,T 是裸参数,两个成员分别求值:string 不满足 extends 返回 string,number 满足返回 never,合并为 string | never,never 被吸收,最终 string。

Q3:T extends never ? A : B 为什么永远是 never?用 IsNever 怎么写?

因为 never 是空联合,分配后无成员可代入,结果还是空联合。正确写法是 type IsNever<T> = [T] extends [never] ? true : false。

Q4:T extends any ? T[] : never 和 T[] 有区别吗?

有。T[] 直接用在联合上得到的是「联合的数组」(如 (string | number)[]),而条件类型分配后得到的是「数组的联合」(如 string[] | number[])。两者不能互相赋值,赋值方向还和 strictFunctionTypes 相关。

Q5:怎么判断一个类型是不是联合类型?

没有完全可靠的运行时判定,但可以用「分配是否会产生多个结果」来近似:type IsUnion<T> = [T] extends [never] ? false : T extends any ? ([T] extends [T] ? false : true) : never 这类写法在多数场景可用,但它对单成员联合和具体类型无法区分,属于工具类型层面的近似手段,面试点到即可。

Q6:IDE 里类型显示为 never,但运行时逻辑正常,要不要管?

要管。类型层的 never 会在下一次改动中变成编译错误,或让某个分支的类型守卫失效。找到第一个产生 never 的泛型调用,确认是空联合导致还是漏了元组包裹,把语义写明确。