TYPESCRIPT / FOUNDATIONS

条件类型的分配与整体检查

区分逐成员变换与整体判断,并应用到共有键与全部键。

本章目录CONTENTS ↓

面对包含字符串和数字的联合,有时我们想提取其中的字符串,有时却要确认整个输入都可以当作字符串使用。这两个需求不同:前者逐成员筛选,后者检查整体。

条件类型的分配让一个变换应用到联合的每个成员;关闭分配则用于整体判断。理解这个区别,还能避免把判断提取成泛型工具时意外改变含义。

为什么相似的判断得到不同结果

逐成员分类时,每个候选类型可以产生自己的结果;判断某个字段是否始终是字符串时,则不能忽略字段中数字的可能性。下面两段代码分别体现这两种求值方式,区别来自执行条件判断的定义形状。

ts
type IsString<V> = V extends string ? true : false;
// IsString<string | number>
// → IsString<string> | IsString<number>
// → true | false(即 boolean)

type StringFlags<T> = {
  [K in keyof T]: T[K] extends string ? true : false
};
type Direct = StringFlags<{ id: string | number }>["id"];
// false:string | number 整体不能赋给 string

条件左侧直接是类型参数 V 时,传入联合会逐成员分配。T[K] 是索引访问类型,不满足这个触发条件。

提取工具,可能改变结果

为了复用逻辑,常会把内联判断提取成一个工具类型。代码看起来只是整理了一下,但新的工具引入了裸类型参数,可能从整体判断变成逐成员判断。类型工具的重构也需要核对行为。

ts
type ViaHelper<T> = {
  [K in keyof T]: IsString<T[K]>
};
type Indirect = ViaHelper<{ id: string | number }>["id"];
// true | false

T[K] 先作为类型实参传给 IsString;真正执行条件判断的定义是 V extends string,因此会分配。

判断依据是条件的定义形状,而不只是传入的类型是否相同:

  • Direct 的条件左侧是 T[K],不分配。
  • Indirect 调用 IsString,其条件左侧是 V,传入联合后分配。

为什么用元组包装来检查整体

当规则要求“输入的所有可能性都满足条件”时,逐个保留合格成员会掩盖不合格成员。把左右两边包装成单元素元组,可以避免裸参数分配,再检查整个输入是否满足要求。只想筛选匹配成员时,则保留裸参数写法。

ts
type FilterStrings<V> = V extends string ? V : never;
type KeepIfAllStrings<V> = [V] extends [string] ? V : never;

type A = FilterStrings<"draft" | 42>; // "draft"
type B = KeepIfAllStrings<"draft" | 42>; // never
type C = KeepIfAllStrings<"draft" | "published">;
// "draft" | "published"

[V] 是单元素元组类型,不是运行时数组。

  • A 逐个筛选"draft" 被保留,42 对应 never
  • B 整体检查:联合中可能有数字,整体不满足条件,得到 never
  • C 整体检查:每个成员都可赋给字符串,保留原类型,不会扩大为 string

为什么需要区分共有键与属性值

一个读取工具在尚未确定对象是哪种成员时,只能直接读取共同存在的属性。另一方面,描述字段类型时又需要知道同一个键可能对应哪些值类型。keyof 和索引访问分别回答这两个问题。

ts
type Entity =
  | { id: string; name: string }
  | { id: number; active: boolean };

type Keys = keyof Entity; // "id"
type Id = Entity["id"];  // string | number

keyof 产生属性名类型。对于这里的普通对象联合,结果是各成员共有的键:两种对象都有 id,但只有第一种有 name,只有第二种有 active。在尚未确定是哪种对象时,不能直接访问后两个属性。

索引访问 Entity["id"] 则取得属性值类型:第一种对象的 idstring,第二种是 number,合起来得到 string | numberkeyof Entity 既不是原对象联合,也不是把所有成员的属性名直接合并。

描述所有可能字段时,分别取键

生成字段说明时,目标可能是列出所有成员出现过的属性名。此时共有键太少,应逐成员取键再合并;但这个结果不适合直接充当任意联合成员都能读取的键范围。

如果目标是收集联合中任一成员出现过的键,可以先分配,再对每个成员取 keyof

ts
type AllKeys<T> = T extends unknown ? keyof T : never;
type EveryKey = AllKeys<Entity>; // "id" | "name" | "active"

// 两个成员分别进入真分支:
// keyof { id: string; name: string }
// | keyof { id: number; active: boolean }
// → ("id" | "name") | ("id" | "active")
// → "id" | "name" | "active"

这里条件左侧是裸类型参数 T,因此联合会分配。两个对象成员都能赋给 unknown,各自进入真分支。取得所有可能出现的键,只是在做类型计算;它不会让某个实际对象自动拥有所有属性。

为什么恒成立的条件也有用

ts
type AllKeysSelf<T> = T extends T ? keyof T : never;
type CommonKeys<T> = keyof T;

type ViaSelf = AllKeysSelf<Entity>; // "id" | "name" | "active"
type Common = CommonKeys<Entity>;  // "id"

对于这里的 AllKeysT extends TT extends unknown 两种写法都成立。使用 unknown 能较直接地表达“让成员通过,然后逐个变换”的意图;这是可读性选择,不是必须遵守的语法要求。

条件并不只负责选择真假分支,它还触发了分配。因此不能因为条件会通过,就把整个表达式简化成 keyof T。后者对联合整体取键,会改变结果。

为什么需要 Extract 筛选完整成员

现有事件联合中可能包含许多事件,而一个完成通知接口只关心保存成功和失败。手写一份缩小后的联合会重复维护 id、error 等字段。Extract 根据条件保留已有的完整成员,使子类型随原定义变化。

已有联合、需要其中符合条件的成员时,直接使用内置 Extract;如果事件数据原本就是按名称组织的映射表,索引访问可能已经足够,不必先改成联合再筛选。

ts
type AppEvent =
  | { kind: "saved"; id: string }
  | { kind: "failed"; error: string }
  | { kind: "progress"; percent: number };

// 内置 Extract 的逻辑:
type MyExtract<T, U> = T extends U ? T : never;

type FinishedEvent = Extract<
  AppEvent,
  { kind: "saved" | "failed" }
>;
// { kind: "saved"; id: string }
// | { kind: "failed"; error: string }

条件左侧是裸类型参数 T,因此逐个检查 AppEvent 的三个成员。saved 和 failed 成员满足目标的 kind 要求;progress 成员不满足,得到 never,不会给最终联合增加成员。

saved 成员可以赋给 { kind: "saved" | "failed" },因为它有目标要求的 kind,且其值类型满足要求。额外的 id 不妨碍这里的类型匹配。真分支写的是 T,所以得到完整的 saved 成员,id 也随之保留。这里讨论类型之间的结构可赋值性,对象字面量的额外属性检查是另一个需要区分的场景。

为什么匹配成功不等于保留原数据

同一个条件既可以用于筛选原成员,也可以用于把成员转换成别的类型。匹配规则只负责选择分支,输出还取决于分支表达式。下面保持判断不变,只替换真分支,观察保留的信息如何变化。

ts
type MatchTarget<T, U> = T extends U ? U : never;

type TargetResult = MatchTarget<
  AppEvent,
  { kind: "saved" | "failed" }
>;
// { kind: "saved" | "failed" }

把真分支从 T 改成 U,匹配规则相同,但两次匹配成功都产出目标类型。结果是两个相同的 Unever 的联合,化简后仍是 U,不再包含 iderror

延伸阅读

这一页,先记到这里。