Tymnastics (1): 标签联合动态派发

在 TypeScript 的标签联合(Tagged Union)上类型安全地动态派发。

什么是 Tymnastics

Tymnastics 即为 TypeScript Magics & Nasty Tactics😏.

TL;DR

/* 标签联合类型 */
type MyUnion = VariantA | VariantB;

interface VariantA {
  kind: "A";
  valueA: string;
}

interface VariantB {
  kind: "B";
  valueB: number;
}

/* 实现的声明 */
interface Impl<T extends MyUnion> {
  getValue: (variant: T) => string;
}

/* 分量标签 */
type Discriminant<T extends MyUnion> = T["kind"];

/* 派发 */
type Dispatch<T extends MyUnion> = (
  T extends any ? (_: { [_ in Discriminant<T>]: Impl<T> }) => any : never
) extends (_: infer R) => any
  ? R
  : never;

/* 实现 + 派发 */
const implA: Impl<VariantA> = {
  getValue: (variant) => variant.valueA,
};
const dispatch: Dispatch<MyUnion> = {
  A: implA,
  B: {
    getValue: (variant: VariantB) => `${variant.valueB}`,
  },
};

/* 动态派发 */
function dynamic_dispatch(union: MyUnion) {
  return dispatch[union.kind] as Impl<MyUnion>;
}

/* DEMO */
let a: MyUnion = {
  kind: "A",
  valueA: "42",
};

let b: MyUnion = {
  kind: "B",
  valueB: 42,
};

let strings = [a, b].map((union) => dynamic_dispatch(union).getValue(union));

标签联合和动态派发

在 Rust 中,有一个 enum_dispatch crate,在参与派发的类型可枚举的情况下,可以通过过程宏为枚举(enum)自动实现要派发的特征(trait),而不需要用到虚表 dyn,可以加速动态派发并减少 boilerplate 工作,同时还保证类型安全(当然,这是静态强类型语言)。

TypeScript 提供了类似 Rust 枚举的标签联合类型(tagged union)。通常的实践是,每一个分量(variant)用一个常量标签(tag, discriminant)标注。

type Union = VariantA | VariantB; // <- 标签联合

interface VariantA {
  kind: "A"; // <- 标签
  valueA: string; // <- 其他数据
} // <- 分量

// ...

TypeScript 的联合,底层仍是 Object 类型,分量的类型在编译期被擦除,所以在运行期的动态派发时,需要利用分量的标签。因此,需要一个从分量标签到该分量对应的实现的映射。

const dispatch = {
  A: {
    getValue: (variant: VariantA) => variant.valueA,
  },
  B: {
    getValue: (variant: VariantB) => `${variant.valueB}`,
  },
};

但是,如何确保这个映射是类型安全的呢?有两个问题需要关注:

  1. 确保分量的标签和分量的实现是类型匹配的。也就是确保在 VariantA 上调用时会被派发到 Impl<VariantA> 的实现。
  2. 所有分量是否都有完整且正确的实现?

标签映射到实现

标签 和实现 存在于值空间,而分量 只存在于类型空间,前两者都由分量类型确定。因此,为保证映射的正确性,需要遍历联合类型,这在 TypeScript 中应当使用条件类型(conditional type)。联合类型在条件类型中会被展开并逐一计算类型,在计算结果上重新求联合。

type UnboxUnion<T> = T extends any ? Something<T> : never;
                   // ^^^^^^^^^^^^^
                   //                 ^^^^^^^^^^^^
type BoxUnion<T> = Something<T>;

// UnboxUnion<A | B> == Something<A> | Something<B>
// BoxUnion<A | B> == Something<A|B>
type DispatchUnion<T extends MyUnion> = T extends any ? { [_ in Discriminant<T>]: Impl<T> } : never; 

其中索引处的 [_ in Discriminant<T>] 用于字面量类型转换为值。当联合类型作为 DispatchUnion 的类型参数时,每一种类型会分别参与运算(展开?),因为这里泛型 T 并没有被包装在其他类型内。

因此确保 Discriminant<T>Impl<T> 的类型参数始终一致。但得到的结果仍然是 | 连接的多个联合类型:

type _ = DispatchUnion<MyUnion>; // 等于...
type _ = { A: Impl<VariantA> } | { B: Impl<VariantB> }
                            // ^ 错误的

而我们想要的是这些类型的交集类型(intersection)——确保所有分量的实现都是完整且正确的。

逆变

如何从联合类型变为交集类型?这里就需要用到逆变(contravariant)。

函数的参数位置就是发生逆变的一个例子。设 F<T> 表示 (_: T): any,则有如下 Venn 图。

TUT&UTUT|UF<T>F<U>F<T|U>F<T>F<U>F<T&U>

能看出来“逆”,但理解不能?用集合来表示:

所以,在我们的类型标注中, 被记作 F<T|U>,而 被记作 F<T&U>。所以 F<T>F<U> 的交集是 F<T|U>,而并集是 F<T&U>

所以,要想从 T|U 获得 T&U,你需要:

  1. T|U 获得 F<T>|F<U>
  2. F<T>F<U> 的计算结果为 F<R>,推导 R 的类型,RT&U
type StepOne<Union> = Union extends any ? F<Union> : never;
type StepTwo<FuncUnion> = (FuncUnion extends any ? FuncUnion : never) extends F<infer R> ? R : never;

注意第二步仍然需要上文的展开技巧,因为需要根据 F<T>F<U> 的并运算的结果做推断,而不是在 F<T>|F<U> 类型上做推断。如果不展开,TypeScript 会把联合类型视作一个整体

泛型擦除

得到的交集类型,就是我们要的从分量标签到该分量对应的实现的映射的类型。但还有一个小问题:变成了彻底的静态派发。而我们最终想要的是动态派发,即运行时传入一个动态的标签联合类型,派发到正确的实现,而不是传入一个具体的分量,在编译期确定正确的实现。

declare let a: VariantA;
declare let b: VariantB;
declare let u: MyUnion;

let implA = dispatch[a.kind]; // Impl<A>
let implB = dispatch[b.kind]; // Impl<B>

let implU = dispatch[u.kind]; // Impl<A> | Impl<B>

// implU.getValue(?) <- 这里的 ? 应该是什么类型?编译期无法确定!

所以,我们需要一个 (_: MyUnion): Impl<MyUnion> 类型的函数来实现动态派发。具体实现非常简单,对于任何一个 MyUnion 类型的值(可能是任何一个分量),取其标签作为下标,我们构造的 Dispatch<MyUnion> 将它映射到的值一定是其分量对应的 Impl。而我们的静态派发是 <T extends MyUnion>(_: T): Impl<T>。所以,我们只需要把所有的泛型 T 全部擦除为动态的 MyUnion 即可。

function dynamic_dispatch(union: MyUnion) {
  return dispatch[union.kind] as Impl<MyUnion>;
}

好麻烦,还不如用 Rust 🤣。以及,由于没有 HKT,这里的 DiscriminantDispatch 两个泛型每次都需要重新实现一份,还不如用 Rust/C++你的键盘不是有 Ctrl, C, V 三个键吗

参考文献

TypeScript: Union to intersection type.