Flow 交集类型(Intersection Types)完全指南:语法、函数重载、对象组合与不可满足类型
Flow 交集类型(Intersection Types)完全指南:语法、函数重载、对象组合与不可满足类型
本文基于 Flow 官方类型系统文档《Intersections》撰写,完整讲解交集类型的语法、求值语义("全入一出")、函数类型交集(重载)、对象类型交集与顺序敏感的合并规则,以及精确对象交集导致的"不可能类型"陷阱,并结合当前仓库源码(Rust 类型检查内核 rust_port/crates/flow_typing、官方测试集 tests/intersection)给出实现级佐证。读完本文你将掌握:用 & 声明重载函数、在 inexact 对象间做交集、用对象类型展开(object type spread)替代精确对象交集,并理解 Flow 为什么在对象组合上选择与 TypeScript 截然不同的路径。
交集类型(intersection type)表示一个值同时属于一组类型中的每一个,即"既满足 A,又满足 B,还满足 C"。它是并集类型(union,表示"满足其中任意一个")的对偶。在 Flow 中,交集类型由 & 运算符连接,最常见的两大用途是:为函数声明多个调用签名(重载),以及要求一个值同时实现多个 interface。
interface Serializable {
serialize(): string;
}
interface HasLength {
length: number;
}
function func(value: Serializable & HasLength) {
// ...
}
func({
length: 3,
serialize() {
return '3';
},
}); // Works
func({length: 3}); // Error! Doesn't implement both interfaces
上面这个 func({length: 3}) 报错,正是因为 {length: 3} 只满足 HasLength,却不满足 Serializable——交集类型要求传入值同时满足所有成员。
与 TypeScript 的对照 TypeScript 习惯用交集(
A & {b: T})合并对象类型,而 Flow 的习惯是类型层面的展开({...A, b: T})。Flow 的对象类型默认是精确的(exact),而精确对象之间做交集会得到一个不可能类型](#不可能交集类型-impossible-intersection-types);对不精确对象({a: number, ...})做交集则是受支持的。用于表达多个调用签名的函数重载交集,两个语言的行为则直接对应。完整对比见 [Object spread typing 的 Object 组合一节。
何时使用交集类型
- 声明重载函数:用函数类型的交集表达"根据传入参数的不同返回不同类型",见函数类型的交集;
- 要求一个值实现多个接口:例如
Serializable & HasLength,需要值同时满足多个 interface(见 interfaces 文档); - 组合不精确对象:
{a: number, ...} & {b: boolean, ...}可以得到同时含两组属性的对象。
而组合精确对象类型时,应当改用对象类型展开(object type spread)——因为精确对象相交会产生不可能类型。
交集类型语法
交集类型是由 & 连接任意多个类型:
Type1 & Type2 & ... & TypeN
也可以在最前面带一个前导 &,这在把长交集拆成多行时非常有用:
type Foo =
& Type1
& Type2
& ...
& TypeN
交集的每个成员可以是任意类型,甚至本身又是一个交集类型:
type Foo = Type1 & Type2;
type Bar = Type3 & Type4;
type Baz = Foo & Bar;
交集类型:全部入,一个出
交集类型与并集类型恰好相反。调用一个接受交集类型的函数时,传入的值必须同时满足全部成员类型(all in);但在函数内部,我们只需要把它当作其中任意一个类型来使用(one out)。
type A = {a: number, ...};
type B = {b: boolean, ...};
type C = {c: string, ...};
function func(value: A & B & C) {
const a: A = value;
const b: B = value;
const c: C = value;
}
即使我们依次把 value 当作 A、B、C 中单独的一个来使用,也不会报错——因为它同时满足所有成员。这种"一次满足、处处可用"的语义,正是交集类型作为接口组合工具的根基。
函数类型的交集
交集类型最经典的用途是表达重载函数:根据传入参数的不同,函数返回不同类型。假设我们要为一个函数写类型,其行为是:
- 传入
"string"时返回string; - 传入
"number"时返回number; - 传入其他任意字符串时返回任意可能的类型(
unknown)。
这个函数的类型可以写作:
type Fn =
& ((x: "string") => string)
& ((x: "number") => number)
& ((x: string) => unknown);
上面定义中的每一行称为一个 overload(重载),我们说 Fn 类型的函数是 overloaded(重载的)。
注意箭头类型周围的圆括号是必需的:它们用来覆盖"箭头构造符"高于交集运算符的优先级,若不写括号,(x: "string") => string & (...) 的解析结果将完全不同。
调用一个重载函数
基于上面的定义,可以声明一个行为如下的函数 fn:
declare const fn:
& ((x: "string") => string)
& ((x: "number") => number)
& ((x: string) => unknown);
const s: string = fn("string"); // Works
const n: number = fn("number"); // Works
const b: boolean = fn("boolean"); // Error!
Flow 实现这一行为的方式是:把实参类型与第一个参数类型兼容的重载进行匹配。注意 "string" 同时匹配第一个和最后一个重载,Flow 只会取第一个。如果没有重载匹配,Flow 会在调用点报错。
声明重载函数
声明同一个 fn 的等价方式是连续使用多条 declare function 语句:
declare function fn(x: "string"): string;
declare function fn(x: "number"): number;
declare function fn(x: string): unknown;
Flow 的一个限制是:它无法针对交集类型检查函数体。换句话说,如果在上述声明之后紧接着给出如下实现:
function fn(x: unknown) {
if (x === "string") { return ""; }
else if (x === "number") { return 0; }
else { return null; }
}
Flow 会静默接受它(并以 Fn 作为推断出的类型),但不会用这个签名去检查实现体。这意味着这类声明更适合放在库定义(libdefs)中——那里实现是被省略的。在仓库中,流式声明(declare function)与 declare const 等环境声明形式,正是 Flow 与 TypeScript 对照文档中列出的两语言直接对应(match)的语法形式之一。
对象类型的交集
当对不精确对象类型(inexact object types)取交集时,含义是:该对象满足交集中的每一个成员。
例如,对两组属性各不相同的不精确对象取交集,结果是同时拥有所有属性的对象:
type One = {foo: number, ...};
type Two = {bar: boolean, ...};
type Both = One & Two;
const value: Both = {
foo: 1,
bar: true
};
当属性名重叠时,Flow 采取与重载函数相同的策略:返回匹配该名字的第一个属性所属的类型。
例如,把两个都含 prop 属性的不精确对象合并——第一个 prop 类型为 number,第二个为 boolean——访问 prop 会得到 number:
type One = {prop: number, ...};
type Two = {prop: boolean, ...};
declare const both: One & Two;
const prop1: number = both.prop; // Works
const prop2: boolean = both.prop; // Error!
这一行为与仓库中的测试互相印证:在 tests/intersection/lib/lib.js 中定义了 type ObjAssignT = { foo: string, ... } & { bar: string, ... }; 等对象交集类型,而 tests/intersection/objassign.js 则验证"交集的对象的展开赋值"(let y: ObjAssignT = { ...x };)是允许的;tests/intersection/intersection.js 直接测试了 Error & {type: number} 的交叉类型可以正常读取其成员属性 x.type。
要组合精确对象类型,应该改用对象类型展开(object type spread):
type One = {foo: number};
type Two = {bar: boolean};
type Both = {
...One,
...Two,
};
const value: Both = {
foo: 1,
bar: true
};
注意:就对象而言,Flow 中交集类型"按顺序"的实现方式,从集合论角度常常显得反直觉——在集合里交集的运算对象可以任意交换顺序(满足交换律)。因此,更好的实践是用顺序语义更明确的对象类型展开来定义这类操作。这一点在 objects 文档中有更完整的展开:对象类型展开直接镜像了运行时值展开的语义——只含自有属性(所以不能展开 interface,因为 interface 不跟踪属性是否为"own")、后者覆盖前者、精确性会传播。
从实现层面看,Flow 的 Rust 类型检查内核在 rust_port/crates/flow_typing/src/merge.rs 中以 IntersectionT 表示交集类型(TypeInner::IntersectionT(_, rep)),并在求值、查找属性键(find_key_prop)等环节逐一遍历成员;例如判断一个交集类型是否"可为 void"时要求所有成员都 voidable(rep.members_iter().all(...)),这与文档"交集要求全部满足"的语义完全一致。解析端则在 rust_port/crates/flow_parser/src/estree_translator.rs 生成 IntersectionTypeAnnotation 节点,& 也登记为解析器的标点符号(见 rust_port/crates/flow_parser_wasm/src/bin/codegen.rs 中 ("IntersectionType", "&", "Punctuator"))。
不可能交集类型
利用交集类型,可以构造出运行时不可能存在的类型。交集允许你组合任意类型,哪怕它们互相冲突。
例如,可以创建 number 与 string 的交集:
type NumberAndString = number & string;
function func(value: NumberAndString) { /* ... */ }
func(3.14); // Error!
func('hi'); // Error!
你无法造出一个"既是数字又是字符串"的值,但可以写出它的类型。这类类型没有实际用处,它只是交集类型工作机制的副产品。
一种很容易意外制造不可能类型的方式,是对精确对象类型取交集。例如:
function func(obj: {a: number} & {b: string}) { /* ... */ }
func({a: 1}); // Error!
func({b: 'hi'}); // Error!
func({a: 1, b: 'hi'}); // Error!
一个对象不可能"恰好只有属性 a 且没有其他属性",同时"恰好只有属性 b 且没有其他属性"——所以三者全部报错。这也正是 flow-vs-typescript 文档 所解释的:因为 Flow 的精确对象禁止未列出的属性,两个精确对象交集会产生不可能类型——只要 A 与 B 有任何差异,一个值不可能同时"恰好是 A"且"恰好是 B"。因此 Flow 需要另一种操作来组合精确对象,即类型层面的展开 {...A, ...B}。
参见
- Unions(并集类型) —— 交集的对偶:表示"一组类型中的某一个"的值;
- Interfaces(接口) —— 定义可被交集组合的类型的常用方式;
- Objects(对象) —— 包括对象类型展开(object type spread),这是组合精确对象类型的推荐方式;
- Flow vs TypeScript 对照 —— 两种语言在对象组合(交集 vs 类型展开)上的完整对比。