Typescript 理解Conditional Types

1,465 阅读5分钟

本文的起因是在做类型体操时出现了一些诡异的行为。题目链接在这,有兴趣的朋友可以看看题目,最后会来类型体操一下。

问题

简单描述这个诡异的行为就是

type a = never extends never ? true : false           // true
type case1<T = never> = never extends T ? true : false    
type case2<T = never> = T extends never ? true : false   
type b = case1                                        // true
type c = case2                                        // never 

奇怪的地方发现没有,正常情况下,never 是可以 extends never的,见 type a、type b,结果true。但在type c中,T extends never ? true : false理论上会被实例化成never extends never ? true : false ,预期结果是true,但却出现了never

要解决这个问题。首先就涉及到 extends 是什么含义?

子类型

Typescript中,如下的代码是可以正常运行的,func接收类型为 TypeA的参数,而obj作为一个类型为TypeB的字面量对象,可以正常传入func中。我们可以称TypeBTypeA的子类型。

interface TypeA {
    a: string;
}

interface TypeB {
    a: string;
}

function func(x: TypeA) {}

const obj: TypeB = { a: 'a' };

func(obj) // OK

extends的角度讲,如果 TypeA 可以 extends TypeB,那么说明TypeATypeB的子类型(儿子:D)。

image.png

名义子类型

在一些其它的编程语言中,要给TypeA创建一个子类型TypeB,必须要显式声明这种父子关系。比如

interface TypeB extends TypeA {}

如果不显式声明,编译器会报错。这就是名义子类型

结构子类型

Typescript中,不需要显式的声明,只需要结构上相似既可。我们称之为结构子类型。

TypeATypeB,结构完全一致,它们的父子关系是相互的,既可以说TypeATypeB的子类型,也可以说TypeBTypeA的子类型。

如果TypeB改写成如下的形式,那么TypeA就无法成为TypeB的子类型了,也就是说子类型必须不能比父类型缺少属性(可以多)。

interface TypeB {
    a: string;
    b: string;
}

Conditional Types

Conditional Types指条件类型,长下面这样。当SomeType 是 OtherType的子类型时就会走到TrueType,否则走到FalseType

SomeType extends OtherType ? TrueType : FalseType;

具体例子

type A = 1 extends 1 ? true : false; // true

但这依然解释不了下面这种情况。明明不论TrueTypeFalseType都没有never

type case2<T = never> = T extends never ? true : false 
type c = case2 // never

Distributive Conditional Types

当条件类型在一个泛型类型身上发生时,就会变成Distributive Conditional Types。举个例子

type Distributive<T> = T extends unknown ? [T] : never;
type A = Distributive<string | number>;

按上文的逻辑来推理,去掉泛型后,A可以转化成如下的内容,最后结果是[string | number]

type A = string | number extends unknown ? [string | number] : never;

但实际运行后发现,A的结果是[string] | [number]

官方文档的说明如下

When conditional types act on a generic type, they become distributive when given a union type.

由于type A = Distributive<string | number>,传入了一个Union Type,因此实际上去掉泛型后,A长这样。

type A = (string extends unknown ? [string] : never) | (number extends unknown ? [number] : never)

要注意的是,触发Distributive Conditional Types的条件如下,以下面的代码为例。

type Distributive<T> = T extends unknown ? [T] : never;
  1. 泛型参数必须在extends的左侧,不能在右侧type Distributive<T> = unknown extends T ? [T] : never

  2. 必须是naked type。可以理解为裸露的类型,通俗来讲,就是说不能对泛型参数做任何的修饰,必须是单纯的T。比如type Distributive<T> = T | 1 extends unknown ? [T] : never中的T | 1已经不单纯了。

  3. 必须是Union type

结论

那么如何去解释这段代码呢

type case2<T = never> = T extends never ? true : false 
type c = case2 // never

众所周知,never在typescript中指不存在的类型。而Union type代表一个集合,never的意思就是一个空的集合。在Distributive Conditional Types的场景下,extends能够表示遍历集合的含义。而遍历一个空的集合never,自然得到的结果也是空never

那么有观众就要问了,为什么never也是Union type呢?never其实是|运算的幺元。所有的类型都可以理解为Union type

比如,type U<T> = T | never,我们会得到U的类型是T,通俗理解就是你拥有了一个类型T,此时还拥有一个不存在的东西never,那么最终你手上有的还只是T。所以TT | never是恒等的。

那么如何去解决这个问题呢?就是不触发Distributive Conditional Types既可,最简单的方法就是不要让Tnaked type。如下的代码,只要我们把T包裹住即可。

type case2<T = never> = [T] extends [never] ? true : false 
type c = case2 // true

image.png

最后

回到最初的题目。题目链接在这。答案链接在这

题目要求传入两个泛型参数,第一个参数是[1, 2, null, 3],第二个参数是null,预期最终把数组的的null去掉,得到[1, 2, 3]。诶这不就是JS里面的filter嘛。于是我们的逻辑很简单,遍历数组中的元素,如果是第二个参数的子类型,就过滤掉,如果不是,就保留。

image.png

type FilterOut<T extends any[], F, R extends any[] = []> = 
    T extends [infer H, ...infer P] 
        ? ([H] extends [F] ? FilterOut<P, F, R> : FilterOut<P, F, [...R, H]>) 
        : R

我们一步步来解释这段代码

  1. 首先T extends [infer H, ...infer P]这个条件类型可以在extends的子句中拿到HPH是数组第一个元素,P是数组剩余的元素形成的数组。

  2. 我们前面初始化了一个空数组R作为FilterOut的结果,可以理解为JS中的一个局部变量。当HF的子类型时,即[H]可以 extends [F],我们就过滤掉H,对剩余元素P进行处理,走到FilterOut<P, F, R>。否则,我们就把H加入到结果数组R中,走到FilterOut<P, F, [...R, H]>

  3. 递归到最后,我们的终止条件是P为空数组,而PFilterOut的第一个参数。空数组时,T extends [infer H, ...infer P] 失败,直接返回结果数组R