Typescript 协变与逆变转换联合类型为交叉类型

最近在搬砖的时候,遇到一个场景,需要根据已存在的联合类型,将其转为交叉类型:

type Preson = {  
  name: string
} | {
  age: number
} | {
  needMoney: boolean
}

type Result = Uinon2Intersection

期望通过 Uinon2Intersection 转换后,得到的 Result

type Result = {  
  name: string
} & {
  age: number
} & {
  needMoney: boolean
}

刚开始感觉很简单。我想已经会了类型体操基本动作四件套了。通过遍历联合类型,然后遍历的时候通过 key 读取属性值就行了,我啪啪啪就写出来了,就像这样:

type U2I = {
  [key in keyof T]: T[key]
}

type Result = U2I

实际得到的是:

type Result = U2I<{
    name: string;
}> | U2I<{
    age: number;
}> | U2I<{
    needMoney: boolean;
}>

Nmmm,这完全不是我期望的样子啊,然后又想了想基础四件套,感觉遇到坑了,好像仅靠四件套并不能解决啊。

先说下,上面这种情况是因为对于联合类型,在遍历操作或者进行条件类型判断的时候,会发生类型分配。就像下面:

type ToArray = Type extends any ? Type[] : never;

type StrArrOrNumArr = ToArray;

其实得到:

type StrArrOrNumArr = string[] | number[]

如果想得到: (string | number)[]。你需要这么写:

type ToArrayNonDist = [Type] extends [any] ? Type[] : never;

回到正文,如果说我们想通过一个工具类型实现联合类型到交叉类型的转换,那需要了解一下下面几个 ts 关键概念:协变、逆变、双向协变、不变性。

类型兼容与可替代性

我们先说说类型兼容与可替代性,因为这两个概念与协变、逆变密切相关。

Typescript 的类型兼容特性是基于类型结构的,其基本规则是:如果 y 类型至少有一些与 x 类型相同的成员,则 x 类型与 y 类型兼容

例如,设想一个名为 Pet 的接口的代码,该接口有一个 name 属性;一个名为 Dog 的接口,该接口有 namebreed 属性。像下面这样:

interface Pet {
  name: string;
}
interface Dog {
  name: string;
  breed: string
}

let pet: Pet;
// dog's inferred type is { name: string; owner: string; }
let dog: Dog = { name: "大哥", breed: "罗威纳" };
pet = dog;

dog 对象中存在 Pet 接口中最基本的属性成员 name。则 dog 可以赋值给 pet 变量。

这是因为 Dog 属性中存在与 Pet 相同的属性。

我们写一个 IsSubTyping 工具类型,用于判断类型之间的继承关系:

type IsSubTyping = T extends U ? true : false

type R0 = IsSubTyping // true

通过上面代码,我们得到的 R = true。这说明,虽然我们没有显示的声明 Dog extends Pet,但是 ts 内部判断这两个接口是兼容且继承的。

其实更符合规范的就是显示声明继承

interface Pet {
  name: string;
}

interface Dog extends Pet {
  breed: string;
}

let pet: Pet = { name: "宠物" };
let dog: Dog = { name: "大哥", breed: "罗威纳" };

pet = dog; // Ok
dog = pet; // Error
// Property 'breed' is missing in type 'Animal' but required in type 'Dog'.

dog 赋值给 animal 是可以的,但是反过来不行。这是因为 DogPet 的子类型。

Typescript 协变与逆变转换联合类型为交叉类型_第1张图片

继承就是实现多态性的一种方式。两个类型是继承关系,那么其子类型的变量,则与其父类型的变量存在可替代性关系,就像上面的 petdog 一样。

pet 变量可以接受 dog 变量。

这个特性在函数传参时非常便利:

function logName(pet: Pet) {
  console.log(pet.name)
}

logName(pet) // Ok
logName(dog) // Ok

logName 函数的参数为 Pet 类型,则其也可以接受 Pet 的子类型。这就是类型的可替代性,在实际开发中,你一定已经无意识的使用到这一特性了。

type T1 = IsSubTyping<'hello', string>; // true
type T2 = IsSubTyping<42, number>; // true
type T3 = IsSubTyping, Object>; // true
这里我们引入一个符号 <:。如果 A 是 B 的子类型,则我们可以使用 A <: B 来表示。

协变

那所谓的协变是什么?

在类型编程中,我们经常会将一个类型以泛型的形式传给另一个类型。

比如说我们现在声明 PetListDogList

type PetList = Array
type DogList = Array

type T4 = IsSubTyping // true

这里发生一个非常有意思的现象:

Dog <: Pet,则 DogList <: PetList 也是成立的。

为此我们这样定义这一特性:

如果某个类型 T 可以保留其他类型之间的关系,那么它就是可协变的。即如果 A <: B,则 T <: T
Typescript 协变与逆变转换联合类型为交叉类型_第2张图片

在 ts 中常见的一些可协变类型:

  • Promise
type T5 = IsSubTyping, Promise> // true
  • Record
type T6 = IsSubTyping, Record> // true
  • Map
type T7 = IsSubTyping, Map> // true

逆变

逆变与协变相反,它可以反转两个类型之间的关系:

如果某种类型 T 可以反转其他类型之间的关系,那么它就是可逆变的。即如果 A <: B,则 T :> T成立。

这种情况通常发生在泛型函数中,我们定义一个泛型函数类型:

type Func = (param: Param) => void

我们知道 Dog <: Pet 。则当我们将这两个接口传给 Func 类型后,获取的类型关系时,是怎样的?

让我们试一试:

type PetFunc = Func
type DogFunc = Func

type T8 = IsSubTyping // false

type T9 = IsSubTyping // true

IsSubTyping 返回 true。意味着 PetFuncDogFunc 的子类型。

DogPet 两个类型在经过 Func 处理后,继承关系发生了反转,我们就说 Func 是可逆变的。

Typescript 协变与逆变转换联合类型为交叉类型_第3张图片

通常函数类型在处理参数的时候都会发生逆变。函数类型的父子类型关系与参数类型的父子关系相反。

type FuncPet = (pet: Pet) => void
type FuncDog = (dog: Dog) => void

type T10 = IsSubTyping // true
type T11 = IsSubTyping // true

其实到这里我们已经可以利用逆变的特性,解决开头提到的将一个联合类型转为交叉类型需求了:

  • 一个联合类型一定与其对应的交叉类型兼容:
type S = {
  name: string
} | {
  age: number
} | {
  needMoney: boolean
}

type I = {
  name: string
} & {
  age: number
} & {
  needMoney: boolean
}

type IsSub = IsSubTyping // true
  • 为此可以利用泛型函数类型参数会发生逆变的特性,实现工具类型 U2I
type U2I =
  (U extends unknown ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

在上面的示例中,我们将泛型应用于了函数的参数,由此产生了逆变。如果我们将泛型应用于函数返回值呢? 是会发生逆变还是协变?

type PetFunc = () => Pet
type DogFunc = () => Dog

type T12 = IsSubTyping // true
type T13 = IsSubTyping, DogFunc> // false
type T14 = IsSubTyping, DogFunc> // true

通过上面的代码,我们可以知道上面泛型类型的函数返回值发生了协变。由此可以知道,函数类型的特殊之处在于其结合了逆变与协变:参数会发生逆变而返回值类型会返回协变。

Typescript 协变与逆变转换联合类型为交叉类型_第4张图片

双向协变

当类型 T 可以使其他类型之间即产生协变又产生逆变的关系,我们称之为双向协变。双向协变与 strictFunctionTypes 配置项的开启相关,当 strictFunctionTypes 没有开启时,下面的代码不会给出任何错误提示。

开启双向协变,你需要在 Ts Config 中关闭 strictFunctionTypes
type PrintFn = (arg: T) => void

interface Pet {
  name: string;
}

interface Dog extends Pet {
  breed: string;
}

declare let f1: PrintFn; // f1: (x: Animal) => void
declare let f2: PrintFn; // f2: (x: Dog) => void

f1 = f2; // Ok
f2 = f1; // Ok

如果在 ts config 中开启 strictFunctionTypes ,当进行 f1 = f2 操作时,会给出如下错误提示:

Type 'PrintFn' is not assignable to type 'PrintFn'.
  Property 'breed' is missing in type 'Pet' but required in type 'Dog'.

如果按照继承的思想, Dog 类型明显属于 Pet 类型,所以 PrintFn 完全可以赋值给 PrintFn

可是当你开启 strictFunctionTypes 配置的时候,函数类型参数的位置被 ts 限制为是逆变,而非双向协变。

ts 编译器会在赋值函数对象时,对函数的参数和返回值执行子类型兼容性检查,发现 Dog <: Pet,进行逆变操作应该为 PrintFn <: PrintFn,而不是 PrintFn <: PrintFn,故给出错误提示。

对于函数参数双向协变感兴趣的可以,跳转:

https://github.com/Microsoft/TypeScript/wiki/FAQ#why-are-function-parameters-bivariant

不变性

所谓不变性是指类型 T 既不会让两个类型之间产生协变,也不会产生逆变。 如果 A <: B, 则 T <: T 既不为 trueT <: T 也不为 true.

以下面代码为例

type IdentityFn = (arg: T) => T;

type T13 = SubtypeOf, IdentityFn>; // false
type T14 = SubtypeOf, IdentityFn>; // false

let petFn: IdentityFn = (pet: Pet) => {
  return pet;
};

let dogFn: IdentityFn = (dog: Dog) => {
  return dog;
};

petFn = dogFn; // Error
dogFn = petFn; // Error

上述代码报错,是因为一个是因为协变检测失败,一个是因为逆变检测失败,所以函数类型 IdentityFn 既不支持协变,也不支持逆变。

总结

通常来说,当你了解了 Ts 的类型兼容特性后,协变与逆变是非常好理解的。协变与逆变的出现,都是为了类型访问的安全性。协变类型,类似于类型的属性收缩,仅需满足基本的类型结构,即可保证类型属性的访问安全,实现继承关系;也而逆变类型,通常发生在泛型函数类型中,而函数会多一层访问空间,ts 并不会知道用户未来会访问参数的哪些属性,则安全的做法就是进行类型属性扩展,也就是逆变。

参考:

你可能感兴趣的:(Typescript 协变与逆变转换联合类型为交叉类型)