Union to Tuple: 协变、逆变与 TypeScript 魔法

免责声明:

笔者

从未接受过 PLT(Programming Language Theory) 教育

从未系统学习过范畴论

从未系统学习过类型理论

描述难免有误,请批评指正

前置知识:协变和逆变

协变和逆变是指(存在继承关系的)类型参数在类型系统中的变化⽅式。

假设存在⼀个泛型类型 Foo<T> 和两个类型 AB

那么:

协变代表:如果 AB 的⼦类型,则 Foo<A> 也是 Foo<B> 的⼦类型。

换句话说,它们保留了原类型的⽗⼦关系,在⼦类型 Foo<A> 的实例赋值给 Foo<B> 类型的参数时,AB 同样是⼦类型赋值给⽗类型

&nbsp;

逆变代表:如果 AB 的⼦类型,则 Foo<B>Foo<A> 的⼦类型。

换句话说,它们逆转了原类型的⽗⼦关系,在⼦类型 Foo<B> 的实例赋值给 Foo<A> 类型的参数时,AB 变成了⽗类型赋值给⼦类型

为了更好的理解协变和逆变与参数位置的关系,我们还需要了解输⼊位置和输出位置的定义

输⼊位置和输出位置

输出位置

输出位置指的是类型参数作为⽅法的返回类型或属性的读操作的类型。
这种情况下,该类型的数据被⽣产,提供给外部消费,因此称为输出位置。

  • 函数的返回类型:泛型类型参数作为函数/⽅法的返回类型。
1
2
3
4
5
6
interface Foo<T> {
// T 是输出位置
method(): T
}
// T 还是输出位置
type Foo<T> = () => T
  • 属性的访问器:泛型类型参数作为属性的读操作的类型。
1
2
3
4
5
6
interface Foo<T> {
// T 是输出位置
property1: T
// T 还是输出位置
get property2(): T
}

输⼊位置

输⼊位置指的是类型参数作为⽅法的参数类型。
这种情况下,该类型的数据由外部提供,并被消费,因此称为输⼊位置。

  • 函数的参数类型:泛型类型参数作为函数/⽅法的参数类型传⼊。
1
2
3
4
5
6
interface Foo<T> {
// T 是输⼊位置
method(arg: T): void
}
// T 也是输⼊位置
type Foo<T> = (args: T) => void

输出位置是协变的,输⼊位置是逆变的

输出位置是协变的,在输出位置上声明⽗类型时,意味着外部只能读取到⽗类型的字段,⽽⼦类型拥有全部⽗类型的字段,因此在输出位置上,将⼦类型赋值给⽗类型是安全的,也就是 Foo<A>Foo<B> 可以维持原类型 AB 的⽗⼦关系。

但对于输⼊位置的类型参数来说,情况就不太⼀样了,如下⾯的例⼦所⽰:

1
2
3
4
5
6
7
8
9
10
11
12
13
type Foo<T> = (args: T) => void
interface A {
a: string
}
interface B extends A {
b: string
}
const fooA: Foo<A> = args => {
console.log(args.a.toString())
}
const fooB: Foo<B> = args => {
console.log(args.a.toString(), args.b.toString())
}

在这个例⼦中, BA 的⼦类型,但 Foo<B> 不是 Foo<A> 的⼦类型,为什么?

⾸先, fooB 函数⼊参声明了接收类型为 B 的参数,也就意味着 fooB 内部可能读取并使⽤ B
的任意⼀个字段。

假设 Foo<B>Foo<A> 的⼦类型,那就意味着 fooB 可以赋值给 fooA,我们将 fooB 赋值给 fooA 看看会发⽣什么:

1
2
// 假设可以被赋值
fooA = fooB

此时,fooA 的签名为

1
(args: A) => void

这意味着使⽤⽅只需要传⼊ A 所要求的字段即可,所以下⾯的调⽤是合法的

1
2
3
fooA({
a: '123',
})

但这个函数签名对应的实现是

1
2
3
args => {
console.log(args.a.toString(), args.b.toString())
}

调⽤⽅传⼊的 args 中根本不存在 b 这个字段,所以这个函数⼀定会报错
cannot read properties of undefined

因此, Foo<B> 不是 Foo<A> 的⼦类型,恰恰相反: Foo<B>Foo<A> 的⽗类型,因为当Foo<A> 的实现赋值给 Foo<B> 这个类型时, Foo<B> 的签名中 B 保证了调⽤⽅⼀定会传⼊⾜够的字段,函数调⽤的安全性得到了保证。

BA的⼦类型,但 Foo<B>Foo<A> 的⽗类型,当我们将 Foo<A> 的实现赋值给 Foo<B> 时,在类型参数的位置上,是 A 被赋给了 B ,⽗类型被赋给了⼦类型,因此,输⼊位置是逆变的。

事实上,就像 C# ⼀样, TypeScript 也可以使⽤ in, out 关键字对类型参数进⾏标注:

1
2
type Foo<in T> = (args: T) => void
type Bar<out T> = () => T

体操开始

UnionToTuple 是⼀个⼯具类型,它接收⼀个联合类型,返回由组成联合类型的类型所构成的元组

1
type Tuple = UnionToTuple<string | number> // 结果为 [string, number]

在 type-challenges 中,这个类型涉及两道 Hard 题⽬

UnionToIntersection

在有了对协变和逆变的认知之后,这个类型的实现将会变得⾮常简洁

1
2
3
type UnionToIntersection<T> = (T extends any ? (t: T) => void : never) extends (t: infer U) => void
? U
: never

根据 TypeScript Handbook 中 TypeScript 2.8 的更新⽇志中 Type inference in conditional types ⼀节的内容可知:

  1. 在协变位置的多个类型被 infer 到同⼀个类型变量时,会组成联合类型
1
2
3
4
type Foo<T> = T extends { a: infer U; b: infer U } ? U : never
type T10 = Foo<{ a: string; b: string }> // string
// 组成了 string | number
type T11 = Foo<{ a: string; b: number }> // string | number
  1. 在逆变位置的多个类型被 infer 到同⼀个类型变量时,会组成交叉类型
1
2
3
4
type Bar<T> = T extends { a: (x: infer U) => void; b: (x: infer U) => void } ? U : never
type T20 = Bar<{ a: (x: string) => void; b: (x: string) => void }> // string
// 组成了 string & number
type T21 = Bar<{ a: (x: string) => void; b: (x: number) => void }> // string & number

我们再看⼀看这个类型的实现:

1
2
3
type UnionToIntersection<T> = (T extends any ? (t: T) => void : never) extends (t: infer U) => void
? U
: never

(T extends any ? (t: T) => void : never) 的作⽤是将传⼊的联合类型分配到⼀个函数的参数(逆变)位置上,这⼀步的意图是

1
2
3
4
5
UnionToIntersection<A | B | C>
// 会被转换为
((t: A) => void)) | ((t: B) => void)) | ((t: C) => void)) extends (t: infer U) => void
? U
: never

此时, infer U 的位置上出现了多个候选类型: ABC ,根据官⽅⽂档可知:在逆变位置
的多个类型被 infer 到同⼀个类型变量时,会组成交叉类型,所以 U 被推断为候选类型 AB
C 的交叉类型 A & B & C ,此题解决

UnionToTuple

先贴实现

1
2
3
4
5
6
7
type UnionToIntersection<T> = (T extends any ? (t: T) => void : never) extends (t: infer U) => void
? U
: never
type LastOf<T> = UnionToIntersection<T extends any ? () => T : never> extends () => infer R
? R
: never
type UnionToTuple<T, L = LastOf<T>> = [T] extends [never] ? [] : [...UnionToTuple<Exclude<T, L>>, L]

为什么需要转换为交叉类型?

转换为交叉类型,是为了利⽤ TypeScript 对函数类型的特殊处理

When inferring from a type with multiple call signatures (such as the type of an overloaded function), inferences are made from the last signature (which, presumably, is the most permissive catch-all case). It is not possible to perform overload resolution based on a list of argument types.

函数的类型推断从最后⼀个签名开始的

⽐如如下所⽰的代码,类型推断就是直接取了最后⼀个签名中的 string | number

1
2
3
4
declare function foo(x: string): number
declare function foo(x: number): string
declare function foo(x: string | number): string | number
type T30 = ReturnType<typeof foo> // string | number

那这和交叉类型有什么关系呢?实际上,上述的类型和下⾯的版本完全等价,函数重载和函数交叉本质上是⼀个东西

1
2
type Foo = ((x: string) => number) & ((x: number) => string) & ((x: string | number) => string | number)
type T31 = ReturnType<Foo> // T31 同样是 string | number

也就是说,借助交叉类型和 TypeScript 对函数签名的特殊处理,我们就可以获得联合类型中的⼀个类型,这就为我们依次处理这些类型打下了基础

LastOf

根据上⾯提到的 TypeScript 特性,我们可以写出这样⼀个类型,⽤于提取⼀个联合类型中的最后⼀个类型:

先贴实现:

1
2
3
type LastOf<T> = UnionToIntersection<T extends any ? () => T : never> extends () => infer R
? R
: never

单步解析

把联合类型转换为多个函数签名的交叉类型

1
2
// 通过 T extends any ? () => T : never 完成类型分配
UnionToIntersection<T extends any ? () => T : never>

利⽤ TypeScript 对函数签名推断的特性:只取最后⼀个,来获得联合类型中的最后⼀个类型:

1
2
// 通过 infer R 来读取被放置到函数签名中的类型
extends () => infer R

UnionToTuple 实现

有了 LastOf 类型的帮助,我们就可以依次提取⼀个联合类型中的每⼀个类型,并将它们放置到元组中

1
type UnionToTuple<T, L = LastOf<T>> = [T] extends [never] ? [] : [...UnionToTuple<Exclude<T, L>>, L]

其中, [T] extends [never] 使⽤元组包裹是为了防⽌ T 是联合类型时,触发 TypeScript 的类
型⾃动分发,

Exclude<T, L> 则是为了将已经处理过的「联合类型中的最后⼀个」排除出去,从⽽达到倒序依次处理每⼀个类型的效果,

经过递归调用 UnionToTuple 处理之后,联合类型就会被转换为元组类型。

总结

  1. 利⽤逆变位置类型推断的特性,将联合类型转换为交叉类型 (UnionToIntersection)
  2. 利⽤函数签名类型推断的特性,提取函数的交叉类型中的最后⼀个签名,从⽽提取联合类型中的最
    后⼀个类型 (LastOf)
  3. 通过递归类型,依次处理联合类型中的每⼀个类型,将它们放到⼀个元组中 (UnionToTuple)

Union to Tuple: 协变、逆变与 TypeScript 魔法
http://example.com/2024/08/08/ts-union-to-tuple/
作者
Freesia
发布于
2024年8月8日
许可协议