likes
comments
collection
share

探秘Typescript·类型的窄化

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

探秘Typescript·类型的窄化

背景

Typescript当中,类型是可以组合使用的,这种组合使用的方式,我们叫做:联合类型,举个例子:

type Margin =  "-moz-initial" | "inherit" | "initial" | "revert" | "revert-layer" | "unset" | "auto" | number | ((string & {}) | 0);

// 例如,上述是 css 当中 margin 属性的描述,这边为了容易理解,将它提取了一下关键的类型
// 可以看到,这就是一个类型联合,我们的 margin 可以从联合类型中支持的一种类型里面选择一种作为值的类型

// 需要特别说明一下的是,上面使用到了:(string & {}),可能会让大家很困惑,这到底是干嘛的?
// 我们在使用 margin 属性时,应该都有发现,margin 除了上述给出的几个字面量字符串类型之外,还需要额外支持其他的字符串,如:'20px' 和 '20px auto 30px',那么,为了同时支持字面量字符串和自定义字符串,就可以再联合上(string & {}),这样既可以获得字面量的提示,又支持自定义字符串不报错。
// 来个简单点的例子
type Type = 'kiner' | 'tang' | (string & {});

function test(arg: Type) {

}
test('xxx');// 不报错
test('k');// 不报错,且有字面量提示
// 那么,大家可能会疑惑,为啥不直接联合 string 类型呢?
// 因为 string 类型其实已经包含了前面的字面量了,可以理解为 string 是全集,而前面的字面量是子集,如果直接用 string 类型的话,会将前面的字面量类型覆盖,导致字面量类型输入时没有提示,降低开发体验。
// 其中 {} 代表的并不是一个对象,而是代表一个空类型,即让 string 类型与一个空类型合并,这样,string 不在是前面字面量的全集,同时又保留了 string 类型所有的特性

了解了联合类型之后,我们再来看一下联合类型在实际使用中的一些问题:

type Margin =  "-moz-initial" | "inherit" | "initial" | "revert" | "revert-layer" | "unset" | "auto" | number |((string & {}) | 0);
function addMargin(margin: Margin) {
  margin.substring(0, 1);// 报错:类型“number”上不存在属性“substring”
}

从上面的示例我们可以看出,联合类型虽然可以让我们一个类型拥有多个类型的特性,但却会导致我们在实际使用时,因为不是所有的类型都有共同的属性,当调用的方法或属性并非所有类型共有时,就会报错。

那么,此时,就是类型窄化✨闪亮登场的时候了。

类型的联合与窄化

为了解决上面的问题,我们通常会使用typeof判断一下传入参数的类型在进行处理,如:

type Margin =  "-moz-initial" | "inherit" | "initial" | "revert" | "revert-layer" | "unset" | "auto" | number |((string & {}) | 0);
function addMargin(margin: Margin) {
  if(typeof margin === "string") {
	  margin.substring(0, 1);// 不报错了,且此时查看 margin 的类型,我们可看到,margin 的类型不再是 Maring,而是 "-moz-initial" | "inherit" | "initial" | "revert" | "revert-layer" | "unset" | "auto" | (string & {}) 这几种拥有字符串特性的类型
  } else if(typeof margin === "number") {
    // do ....
  }
}

大家会不会很好奇,我们并没有进行过任何形式的类型断言(关于类型断言的知识,不了解的同学可以看一下上一篇文章:探秘Typescript·TS日常类型(二))将margin断言成string类型,那么为什么在这里就能够直接使用只有string类型才能使用的方法而不报错呢?

这就是类型的窄化(Narrowing)Typescript引擎会根据当前语句所处的上下文,尽可能的帮你推断和窄化类型,从这里看,Typescript还是挺聪明的嘛。当然,窄化类型的方式不只是这一种,我们下面一一来看一下。

窄化类型的几种方式

if + type guard

我们上面举得例子就是通过if + typeof的方式进行类型的窄化,我们可以看到,上面的窄化能力,让Typescript识别出了当前分支代码中的maring到底是string类型还是number类型。

在实现层面来看,Typescript认为typeof margin === "string"是一种**类型守卫(type guard)**表达式,因此,从实现层面上来说,我们可以把上面的说法泛化一下:if + type guard实现了类型的窄化。

总结:类型窄化(Type Norrowing)根据类型守卫(Type Guard)的子语句块重新定义了更加具体的类型

类型守卫

  • string
  • number
  • function
  • bigint
  • boolean
  • symbol
  • undefined
  • object - 需要注意的是:type null === 'object'
function showName(name: string|string[]|null): void {
  if(typeof name === "object") {
    // 在此处,窄化后的类型是 string[] | null
    // 因为 typeof null === "object"
    // 因此我们要使用一些字符串数组才有的方法时,还需要再排除一下 null 的情况
  }
}

真值窄化

Javascript当中,有一张无比复杂的真值表,但总结下来有如下的一些假值:

  • 0数字0
  • 0nbigint 中的0
  • ""空字符串
  • NaN不是数字
  • undefined未定义
  • null空对象

Typescript当中,我们也可以借助这些真值表对类型进行窄化,如:

function showName(name: string|string[]|null): void {
  if(name && typeof name === "object") {
    // 在此处,窄化后的类型是 string[]
  }
}

从上面的代码可以看到,我们根据name是否是真值排除了null的情况,因此,窄化之后的类型不可能是null,只能是string[]

再来看一个例子:

function arr2str(arr?: number[]): string {
  // 由于此处 arr 是可选参,因此,arr 的真实类型为 number[] | undefined
  // 在这里通过真值窄化,排除了 undefined 的情况,在后面就可以直接放心得使用数组的方法了。
  if(!arr) {
    return '';
  }
  return arr.join(',');
}

总结一下:真值窄化能够帮我们更好的应对诸如:undefined、null、0、""等情况

相等性窄化

除了真值窄化之外,我们还可以用相等性比较来窄化类型,例如:

function compare(a: string | number, b: string | boolean) {
  if(a === b) {
    // 此处为相等性窄化,如果 a 和 b 相等,那么 a 和 b 的类型必然也一致,那只可能是 string 类型
    // a is string
    // b is string
  } else {
    // a: string | number
    // b: string | boolean
  }
}

相等性窄化的关键符号有:

  • ===
  • !==
  • !=
  • ==

有同学可能会有疑问了,!===不判断类型,也能够触发窄化吗?我们再来看一个例子:

function log(value: string | null | undefined) {
  if(value != null) {
    value.substring(0,1);// 此处不会报错,也就是 value 的类型就是 string,因为 value != null这个判断,只有 value 的类型为 string 才能进入这个逻辑
  }
}

in 操作符窄化

Javascript中,in操作符是用来检查一个对象中是否拥有某个属性的。举个例子:

type Cat = { move: () => void }
type Fish = { swim: () => void }

function petMove(ani: Cat | Fish): void {
  if("move" in ani) {
    ani.move();// 在这里,类型被窄化为了 Cat,因为只有 Cat 才有 move 方法
  } else {
    ani.swim();// 此处则为 Fish
  }
}

或许有同学会有疑问,我们通常使用instanceof来判断一个对象是否是某个类的实例化对象,这里是否也可以这么使用呢?

答案是在这个实例中不行的,我们的 Cat 和 Fish 仅仅只是Typescript的一个类型别名,他们并不是一个类,也不可能存在实例。

instanceof 窄化

上面我们请特别强调了instanceof在上面的示例中不可以用于窄化,但并不意味着instanceof不能用于所有场景的窄化,如果你的类型是一个实实在在存在的类,而不仅仅是Typescript的类型别名,那么你也可以用instanceof进行窄化。例如:

function valueOf(date: Date | string | number): number {
  if (date instanceof Date) {// 通过 instanceof 将类型窄化为 Date,就可以使用 Date 的方法了
    return date.valueOf();
  }
  if (typeof date === "string") {
    return new Date(date).valueOf()
  }
  return date
}

组合类型的推导窄化

有些时候,Typescript也会很聪明的帮你推断出一些类型组合,例如:

function getInfo(a: number) {
  if(a === 1) {
    return "name";
  } else if(a === 2) {
    return 30;
  } else {
    return true;
  }
}

const info = getInfo(1);// 此处 info 的类型为:true | "name" | 30

从上面的示例可以看出,很多时候,如果我们没有显式指定类型,Typescript会尽可能的帮你推导出来当前数据的类型。当然,我们不推荐这么使用,我们还是推荐尽可能的显示描述类型,以提升程序可读性和避免开发维护时的出错。

窄化的原理

控制流的分析

相信大家学习完上面的各种窄化场景后,一定对Typescript究竟是如何实现这样的窄化?

首先,我们应该都清楚,Typescript是编译阶段的语言,最终运行时还是会被编译成Javascript的。那么,在这个编译的过程中,Typescript编译器会去识别出上面所说的那些类型守卫表达式,其中还包括了一些隐性的类型卫兵,比如真值表达式instanceof等。

然后在进行语义分析时,Typescript会遇到控制流关键字,如:ifwhile等,就会看看这些流程控制所包含的区块内是否有需要进行类型窄化的操作。

type Margin =  "-moz-initial" | "inherit" | "initial" | "revert" | "revert-layer" | "unset" | "auto" | number |((string & {}) | 0);
function addMargin(margin: Margin) {
  if(typeof margin === "string") {
	  margin.substring(0, 1);
  } else if(typeof margin === "number") {
    // do ....
  }
}

例如上面的示例:

  • 查找卫兵:识别出存在卫兵表达式:typeof margin === "string"
  • 流程控制:识别到卫兵表达式处于流程控制语句if当中
  • 进行窄化:针对流程控制语句所包含的区块,将margin进行窄化

其实,窄化的本质就是重新定义类型

类型断言(Type Assertions/Predicate)

AssertionsPredicate翻译过来都是断言的意思。在编程世界里,Assertion通常是断言某个表达式的值是否为truefalse

Assertion

Assertion在很多测试库中都会被使用。如我们前端工程师最常使用的单元测试框架jest当中,就有着大量的断言语句,如:

expect(a).toBe(1);// 断言 a 的值是否为1
expect(fn).toBeCalled();// 断言 fn 是否曾今被调用过
// 断言是否是真值
expect(wrapper.find('.ant-pro-card-collapse').exists()).toBeTruthy();
// 断言调用 fn 时所传递的参数中有 'tab2'
expect(fn).toHaveBeenCalledWith('tab2');

Assertion的本质就是在说明某个东西是什么

Predicate

predicate通常是一个函数,根据传入的数据进行一定的判断,并返回truefalse,例如:

const arr = [0, 1, 2, 3, 4];
arr.filter(item => item > 2);// 其中 item => item > 2 就是一个断言函数

Predicate的本质就是一个返回truefalse的函数。

断言操作符

Typescript中,有两种类型的断言操作符,分别对应了AssertionPredicate,他们分别是:

  • as:对应的是Assertion

    该操作符提醒Typescript引擎,某种类型是什么。(当用户比Typescript更了解该类型应该是什么时使用)

    const canvas = document.getElementById("canvas") as HTMLCanvasElement;
    
  • is:对应的是Predicate

    该操作符是用户定义的类型守卫,用于帮助类型窄化。

    type Cat = { move: () => void };
    type Fish = { swim: () => void };
    // 在这里,pet is Cat 对于当前的这个函数其实并没有什么影响,但他会影响调用这个函数之后 Typescript 的窄化
    function isCat(pet: Cat | Fish): pet is Cat {
      return (pet as Cat).move !== undefined;
    }
    const pet = { swim: () => { } }
    
    if (isCat(pet)) {// 到了这里,执行了方法之后,由于上面的 is 断言,Typescript就知道,此时 pet 的类型是 Cat,因此下面可以直接使用 Cat 的方法。在这里,isCat(pet)就相当于是类型守卫的作用
      pet.move();
    } else {
      pet.swim();
    }
    

判别的联合

interface Shape {
  type: "circle" | "square";
  radius?: number;// 圆的半径
  size?: number;// 正方形的边长
}

function getArea(shape: Shape): number {
  return Math.PI * shape.radius ** 2;// 这里会报错,因为如果 shape 是正方形时,不一定会有 radius 属性的
}

上面的程序,如果想要不报错,应该怎么办呢?或许有些同学会这么想:

function getArea(shape: Shape): number {
  if(shape.type === "circle") {
	  return Math.PI * shape.radius ** 2;// 还是会报错,因为我们虽然判断了类型是 circle,但由于类型定义时 radius 时可选的,所以类型提示会报 radius 可能为空。
  }
}

同学们可能会说,那这个也简单,我学过非空断言!

function getArea(shape: Shape): number {
  if(shape.type === "circle") {
	  return Math.PI * shape.radius! ** 2;
  }
}

这样修改之后,虽然不报错了,但这样真的好吗?要是之后在增加一个rect矩形呢?那我们的程序是不是会越来越复杂和混乱了?

其实上面的问题主要是出在,无论是圆形还是正方形,都是相对独立的形状,我们应该这样设计


interface Circle {
  type: 'circle',
  radius: number
}
interface Square {
  type: 'square',
  size: number
}

type Shape = Circle | Square;

function getArea(shape: Shape): number {
  if(shape.type === "circle") {
	  return Math.PI * shape.radius ** 2;
  }
  return shape.size ** 2;
}

通过上述方式,将不同形状的类型拆分,再通过类型联合将多个类型联合成Shape,那么我们就可以很方便安全的使用不同类型的属性方法了。

Never类型

Never就是不应该出现的意思,never类型代表一个不应该出现的类型,因此,任何对never的赋值操作都会报错。通常就是不希望走到那一个分支才会被指定never

interface Circle {
  type: 'circle',
  radius: number
}
interface Square {
  type: 'square',
  size: number
}

type Shape = Circle | Square;

function getArea(shape: Shape): number {
  switch(shape.type) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
  		return shape.size ** 2;
    default:
      // 这里会报错,类似于 throw 这样下面也可以 return 了,代码风格统一
      const _shape: never = shape;
      return shape;
  }
}

总结

窄化其实就是解决了联合类型的校验问题,能让联合类型在使用中根据不同的流程控制条件重新定义。假如没有窄化,那么我们在使用联合类型时,在某些流程控制语句中,我们是不知道这个数据的具体类型的,就会失去很多的校验能力。

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