likes
comments
collection
share

TypeScript 协变与逆变解决联合类型转交叉类型

作者站长头像
站长
· 阅读数 15

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

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

type Result = Uinon2Intersection<Preson>

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

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

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

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

type Result = U2I<Preson>

实际得到的是:

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

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

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

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

type StrArrOrNumArr = ToArray<string | number>;

其实得到:

type StrArrOrNumArr = string[] | number[]

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

type ToArrayNonDist<Type> = [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, U> = T extends U ? true : false

type R0 = IsSubTyping<Dog, Pet> // 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 协变与逆变解决联合类型转交叉类型

继承就是实现多态性的一种方式。两个类型是继承关系,那么其子类型的变量,则与其父类型的变量存在可替代性关系,就像上面的 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<Map<string, string>, Object>; // true

这里我们引入一个符号 <:。如果 A 是 B 的子类型,则我们可以使用 A <: B 来表示。

协变

那所谓的协变是什么?

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

比如说我们现在声明 PetListDogList

type PetList = Array<Pet>
type DogList = Array<Dog>

type T4 = IsSubTyping<DogList, PetList> // true

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

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

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

如果某个类型 T 可以保留其他类型之间的关系,那么它就是可协变的。即如果 A <: B,则 T<A> <: T<B>

TypeScript 协变与逆变解决联合类型转交叉类型

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

  • Promise
type T5 = IsSubTyping<Promise<Dog>, Promise<Pet>> // true
  • Record
type T6 = IsSubTyping<Record<string, Dog>, Record<string, Pet>> // true
  • Map
type T7 = IsSubTyping<Map<string, Dog>, Map<string, Pet>> // true

逆变

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

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

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

type Func<Param> = (param: Param) => void

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

让我们试一试:

type PetFunc = Func<Pet>
type DogFunc = Func<Dog>

type T8 = IsSubTyping<DogFunc, PetFunc> // false

type T9 = IsSubTyping<PetFunc, DogFunc> // true

IsSubTyping<PetFunc, DogFunc> 返回 true。意味着 PetFuncDogFunc 的子类型。

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

TypeScript 协变与逆变解决联合类型转交叉类型

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

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

type T10 = IsSubTyping<Dog, Pet> // true
type T11 = IsSubTyping<PetFunc, DogFunc> // true

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

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

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

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

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

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

type T12 = IsSubTyping<Dog, Pet> // true
type T13 = IsSubTyping<PetFunc<Pet>, DogFunc<Dog>> // false
type T14 = IsSubTyping<PetFunc<Dog>, DogFunc<Pet>> // true

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

TypeScript 协变与逆变解决联合类型转交叉类型

双向协变

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

开启双向协变,你需要在 Ts Config 中关闭 strictFunctionTypes

type PrintFn<T> = (arg: T) => void

interface Pet {
  name: string;
}

interface Dog extends Pet {
  breed: string;
}

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

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

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

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

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

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

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

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

github.com/Microsoft/T…

不变性

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

以下面代码为例

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

type T13 = SubtypeOf<IdentityFn<Pet>, IdentityFn<Dog>>; // false
type T14 = SubtypeOf<IdentityFn<Dog>, IdentityFn<Pet>>; // false

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

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

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

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

总结

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

参考:

转载自:https://juejin.cn/post/7267477916744925220
评论
请登录