mqyqingfeng / Blog

冴羽写博客的地方,预计写四个系列:JavaScript深入系列、JavaScript专题系列、ES6系列、React系列。

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

TypeScript 之 Narrowing

mqyqingfeng opened this issue · comments

前言

TypeScript 的官方文档早已更新,但我能找到的中文文档都还停留在比较老的版本。所以对其中新增及修改较多的章节进行了个人的翻译整理。

本篇整理自 https://www.typescriptlang.org/docs/handbook/2/narrowing.html

本文并不完全遵循原文翻译,对部分内容自己也做了解释补充。

Narrowing

试想我们有这样一个函数,函数名为 padLeft:

function padLeft(padding: number | string, input: string): string {
  throw new Error("Not implemented yet!");
}

该函数实现的功能是:

如果参数 padding 是一个数字,我们就在 input 前面添加同等数量的空格,而如果 padding 是一个字符串,我们就直接添加到 input 前面。

让我们实现一下这个逻辑:

function padLeft(padding: number | string, input: string) {
  return new Array(padding + 1).join(" ") + input;
	// Operator '+' cannot be applied to types 'string | number' and 'number'.
}

如果这样写的话,编辑器里 padding + 1 这个地方就会标红,显示一个错误。

这是 TypeScript 在警告我们,如果把一个 number 类型 (即例子里的数字 1 )和一个 number | string 类型相加,也许并不会达到我们想要的结果。换句话说,我们应该先检查下 padding 是否是一个 number,或者处理下当 paddingstring 的情况,那我们可以这样做:

function padLeft(padding: number | string, input: string) {
  if (typeof padding === "number") {
    return new Array(padding + 1).join(" ") + input;
  }
  return padding + input;
}

这个代码看上去也许没有什么有意思的地方,但实际上,TypeScript 在背后做了很多东西。

TypeScript 要学着分析这些使用了静态类型的值在运行时的具体类型。目前 TypeScript 已经实现了比如 if/else 、三元运算符、循环、真值检查等情况下的类型分析。

在我们的 if 语句中,TypeScript 会认为 typeof padding === number 是一种特殊形式的代码,我们称之为类型保护 (type guard),TypeScript 会沿着执行时可能的路径,分析值在给定的位置上最具体的类型。

TypeScript 的类型检查器会考虑到这些类型保护和赋值语句,而这个将类型推导为更精确类型的过程,我们称之为收窄 (narrowing)。 在编辑器中,我们可以观察到类型的改变:

image.png

从上图中可以看到在 if 语句中,和剩余的 return 语句中,padding 的类型都推导为更精确的类型。

接下来,我们就介绍 narrowing 所涉及的各种内容。

typeof 类型保护(type guards)

JavaScript 本身就提供了 typeof 操作符,可以返回运行时一个值的基本类型信息,会返回如下这些特定的字符串:

  • "string"
  • "number"
  • "bigInt"
  • "boolean"
  • "symbol"
  • "undefined"
  • "object"
  • "function"

typeof 操作符在很多 JavaScript 库中都有着广泛的应用,而 TypeScript 已经可以做到理解并在不同的分支中将类型收窄。

在 TypeScript 中,检查 typeof 返回的值就是一种类型保护。TypeScript 知道 typeof 不同值的结果,它也能识别 JavaScript 中一些怪异的地方,就比如在上面的列表中,typeof 并没有返回字符串 null,看下面这个例子:

function printAll(strs: string | string[] | null) {
  if (typeof strs === "object") {
    for (const s of strs) {
		  // Object is possibly 'null'.
      console.log(s);
    }
  } else if (typeof strs === "string") {
    console.log(strs);
  } else {
    // do nothing
  }
}

在这个 printAll 函数中,我们尝试判断 strs 是否是一个对象,原本的目的是判断它是否是一个数组类型,但是在 JavaScript 中,typeof null 也会返回 object。而这是 JavaScript 一个不幸的历史事故。

熟练的用户自然不会感到惊讶,但也并不是所有人都如此熟练。不过幸运的是,TypeScript 会让我们知道 strs 被收窄为 strings[] | null ,而不仅仅是 string[]

真值收窄(Truthiness narrowing)

在 JavaScript 中,我们可以在条件语句中使用任何表达式,比如 &&||! 等,举个例子,像 if 语句就不需要条件的结果总是 boolean 类型

function getUsersOnlineMessage(numUsersOnline: number) {
  if (numUsersOnline) {
    return `There are ${numUsersOnline} online now!`;
  }
  return "Nobody's here. :(";
}

这是因为 JavaScript 会做隐式类型转换,像 0NaN""0nnull undefined 这些值都会被转为 false,其他的值则会被转为 true

当然你也可以使用 Boolean 函数强制转为 boolean 值,或者使用更加简短的!!

// both of these result in 'true'
Boolean("hello"); // type: boolean, value: true
!!"world"; // type: true,    value: true

这种使用方式非常流行,尤其适用于防范 nullundefiend 这种值的时候。举个例子,我们可以在 printAll 函数中这样使用:

function printAll(strs: string | string[] | null) {
  if (strs && typeof strs === "object") {
    for (const s of strs) {
      console.log(s);
    }
  } else if (typeof strs === "string") {
    console.log(strs);
  }
}

可以看到通过这种方式,成功的去除了错误。

但还是要注意,在基本类型上的真值检查很容易导致错误,比如,如果我们这样写 printAll 函数:

function printAll(strs: string | string[] | null) {
  // !!!!!!!!!!!!!!!!
  //  DON'T DO THIS!
  //   KEEP READING
  // !!!!!!!!!!!!!!!!
  if (strs) {
    if (typeof strs === "object") {
      for (const s of strs) {
        console.log(s);
      }
    } else if (typeof strs === "string") {
      console.log(strs);
    }
  }
}

我们把原本函数体的内容包裹在一个 if (strs) 真值检查里,这里有一个问题,就是我们无法正确处理空字符串的情况。如果传入的是空字符串,真值检查判断为 false,就会进入错误的处理分支。

如果你不熟悉 JavaScript ,你应该注意这种情况。

另外一个通过真值检查收窄类型的方式是通过!操作符。

function multiplyAll(
  values: number[] | undefined,
  factor: number
): number[] | undefined {
  if (!values) {
    return values;
    // (parameter) values: undefined
  } else {
    return values.map((x) => x * factor);
    // (parameter) values: number[]
  }
}

等值收窄(Equality narrowing)

Typescript 也会使用 switch 语句和等值检查比如 == !== == != 去收窄类型。比如:

image.png

在这个例子中,我们判断了 xy 是否完全相等,如果完全相等,那他们的类型肯定也完全相等。而 string 类型就是 xy 唯一可能的相同类型。所以在第一个分支里,xy 就一定是 string 类型。

判断具体的字面量值也能让 TypeScript 正确的判断类型。在上一节真值收窄中,我们写下了一个没有正确处理空字符串情况的 printAll 函数,现在我们可以使用一个更具体的判断来排除掉 null 的情况:

image.png

JavaScript 的宽松相等操作符如 ==!= 也可以正确的收窄。在 JavaScript 中,通过 == null 这种方式并不能准确的判断出这个值就是 null,它也有可能是 undefined 。对 == undefined 也是一样,不过利用这点,我们可以方便的判断一个值既不是 null 也不是 undefined

image.png

in 操作符收窄

JavaScript 中有一个 in 操作符可以判断一个对象是否有对应的属性名。TypeScript 也可以通过这个收窄类型。

举个例子,在 "value" in x 中,"value" 是一个字符串字面量,而 x 是一个联合类型:

type Fish = { swim: () => void };
type Bird = { fly: () => void };
 
function move(animal: Fish | Bird) {
  if ("swim" in animal) {
    return animal.swim();
    // (parameter) animal: Fish
  }
 
  return animal.fly();
  // (parameter) animal: Bird
}

通过 "swim" in animal ,我们可以准确的进行类型收窄。

而如果有可选属性,比如一个人类既可以 swim 也可以 fly (借助装备),也能正确的显示出来:

type Fish = { swim: () => void };
type Bird = { fly: () => void };
type Human = { swim?: () => void; fly?: () => void };
 
function move(animal: Fish | Bird | Human) {
  if ("swim" in animal) {
    animal; // (parameter) animal: Fish | Human
  } else {
    animal; // (parameter) animal: Bird | Human
  }
}

instanceof 收窄

instanceof 也是一种类型保护,TypeScript 也可以通过识别 instanceof 正确的类型收窄:

image.png

赋值语句(Assignments)

TypeScript 可以根据赋值语句的右值,正确的收窄左值。

image.png

注意这些赋值语句都有有效的,即便我们已经将 x 改为 number 类型,但我们依然可以将其更改为 string 类型,这是因为 x 最初的声明为 string | number,赋值的时候只会根据正式的声明进行核对。

所以如果我们把 x 赋值给一个 boolean 类型,就会报错:

控制流分析(Control flow analysis)

至此我们已经讲了 TypeScript 中一些基础的收窄类型的例子,现在我们看看在 if while等条件控制语句中的类型保护,举个例子:

function padLeft(padding: number | string, input: string) {
  if (typeof padding === "number") {
    return new Array(padding + 1).join(" ") + input;
  }
  return padding + input;
}

在第一个 if 语句里,因为有 return 语句,TypeScript 就能通过代码分析,判断出在剩余的部分 return padding + input ,如果 padding 是 number 类型,是无法达到 (unreachable) 这里的,所以在剩余的部分,就会将 number类型从 number | string 类型中删除掉。

这种基于可达性(reachability) 的代码分析就叫做控制流分析(control flow analysis)。在遇到类型保护和赋值语句的时候,TypeScript 就是使用这样的方式收窄类型。而使用这种方式,一个变量可以被观察到变为不同的类型:

image.png

类型判断式(type predicates)

在有的文档里, type predicates 会被翻译为类型谓词。考虑到 predicate 作为动词还有表明、声明、断言的意思,区分于类型断言(Type Assertion),这里我就索性翻译成类型判断式。

如果引用这段解释:

In mathematics, a predicate is commonly understood to be a Boolean-valued function_ P_: _X_→ {true, false}, called the predicate on X.

所谓 predicate 就是一个返回 boolean 值的函数。

那我们接着往下看。

如果你想直接通过代码控制类型的改变, 你可以自定义一个类型保护。实现方式是定义一个函数,这个函数返回的类型是类型判断式,示例如下:

function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

在这个例子中,pet is Fish就是我们的类型判断式,一个类型判断式采用 parameterName is Type的形式,但 parameterName 必须是当前函数的参数名。

当 isFish 被传入变量进行调用,TypeScript 就可以将这个变量收窄到更具体的类型:

// Both calls to 'swim' and 'fly' are now okay.
let pet = getSmallPet();
 
if (isFish(pet)) {
  pet.swim(); // let pet: Fish
} else {
  pet.fly(); // let pet: Bird
}

注意这里,TypeScript 并不仅仅知道 if 语句里的 petFish 类型,也知道在 else 分支里,petBird 类型,毕竟 pet 就两个可能的类型。

你也可以用 isFishFish | Bird 的数组中,筛选获取只有 Fish 类型的数组:

const zoo: (Fish | Bird)[] = [getSmallPet(), getSmallPet(), getSmallPet()];
const underWater1: Fish[] = zoo.filter(isFish);
// or, equivalently
const underWater2: Fish[] = zoo.filter(isFish) as Fish[];
 
// 在更复杂的例子中,判断式可能需要重复写
const underWater3: Fish[] = zoo.filter((pet): pet is Fish => {
  if (pet.name === "sharkey") return false;
  return isFish(pet);
});

可辨别联合(Discriminated unions)

让我们试想有这样一个处理 Shape (比如 CirclesSquares )的函数,Circles 会记录它的半径属性,Squares 会记录它的边长属性,我们使用一个 kind 字段来区分判断处理的是 Circles 还是 Squares,这是初始的 Shape 定义:

interface Shape {
  kind: "circle" | "square";
  radius?: number;
  sideLength?: number;
}

注意这里我们使用了一个联合类型,"circle" | "square" ,使用这种方式,而不是一个 string,我们可以避免一些拼写错误的情况:

function handleShape(shape: Shape) {
  // oops!
  if (shape.kind === "rect") {
	// This condition will always return 'false' since the types '"circle" | "square"' and '"rect"' have no overlap.
    // ...
  }
}

现在我们写一个获取面积的 getArea 函数,而圆和正方形的计算面积的方式有所不同,我们先处理一下是 Circle 的情况:

function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2; // 圆的面积公式 S=πr²
  // Object is possibly 'undefined'.
}

strictNullChecks 模式下,TypeScript 会报错,毕竟 radius 的值确实可能是 undefined,那如果我们根据 kind 判断一下呢?

function getArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius ** 2;
		// Object is possibly 'undefined'.
  }
}

你会发现,TypeScript 依然在报错,即便我们判断 kindcircle 的情况,但由于 radius 是一个可选属性,TypeScript 依然会认为 radius 可能是 undefined

我们可以尝试用一个非空断言 (non-null assertion), 即在 shape.radius 加一个 ! 来表示 radius 是一定存在的。

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

但这并不是一个好方法,我们不得不用一个非空断言来让类型检查器确信此时 shape.raidus 是存在的,我们在 radius 定义的时候将其设为可选属性,但又在这里将其认为一定存在,前后语义也是不符合的。所以让我们想想如何才能更好的定义。

此时 Shape的问题在于类型检查器并没有方法根据 kind 属性判断 radiussideLength 属性是否存在,而这点正是我们需要告诉类型检查器的,所以我们可以这样定义 Shape:

interface Circle {
  kind: "circle";
  radius: number;
}
 
interface Square {
  kind: "square";
  sideLength: number;
}
 
type Shape = Circle | Square;

在这里,我们把 Shape 根据 kind 属性分成两个不同的类型,radiussideLength 在各自的类型中被定义为 required

让我们看看如果直接获取 radius 会发生什么?

function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2;
Property 'radius' does not exist on type 'Shape'.
  Property 'radius' does not exist on type 'Square'.
}

就像我们第一次定义 Shape 那样,依然有错误。

当最一开始定义 radiusoptional 的时候,我们会得到一个报错 (strickNullChecks 模式下),因为 TypeScript 并不能判断出这个属性是一定存在的。

而现在报错,是因为 Shape 是一个联合类型,TypeScript 可以识别出 shape 也可能是一个 Square,而 Square 并没有 radius,所以会报错。

但这时我们再根据 kind 属性检查一次呢?

image.png

你会发现,报错就这样被去除了。

当联合类型中的每个类型,都包含了一个共同的字面量类型的属性,TypeScript 就会认为这是一个可辨别联合(discriminated union),然后可以将具体成员的类型进行收窄。

在这个例子中,kind 就是这个公共的属性(作为 Shape 的可辨别(discriminant) 属性 )。

这也适用于 switch 语句:
image.png

这里的关键就在于如何定义 Shape,告诉 TypeScript,CircleSquare 是根据 kind 字段彻底分开的两个类型。这样,类型系统就可以在 switch 语句的每个分支里推导出正确的类型。

可辨别联合的应用远不止这些,比如消息模式,比如客户端服务端的交互、又比如在状态管理框架中,都是很实用的。

试想在消息模式中,我们会监听和发送不同的事件,这些都是以名字进行区分,不同的事件还会携带不同的数据,这就应用到了可辨别联合。客户端与服务端的交互、状态管理,都是类似的。

never 类型

当进行收窄的时候,如果你把所有可能的类型都穷尽了,TypeScript 会使用一个 never 类型来表示一个不可能存在的状态。

让我们接着往下看。

穷尽检查(Exhaustiveness checking)


never 类型可以赋值给任何类型,然而,没有类型可以赋值给 never (除了 never 自身)。这就意味着你可以在 switch 语句中使用 never 来做一个穷尽检查。

举个例子,给 getArea 函数添加一个 default,把 shape 赋值给 never 类型,当出现还没有处理的分支情况时,never 就会发挥作用。

type Shape = Circle | Square;
 
function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    default:
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
  }
}

当我们给 Shape 类型添加一个新成员,却没有做对应处理的时候,就会导致一个 TypeScript 错误:

interface Triangle {
  kind: "triangle";
  sideLength: number;
}
 
type Shape = Circle | Square | Triangle;
 
function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    default:
      const _exhaustiveCheck: never = shape;
      // Type 'Triangle' is not assignable to type 'never'.
      return _exhaustiveCheck;
  }
}

因为 TypeScript 的收窄特性,执行到 default 的时候,类型被收窄为 Triangle,但因为任何类型都不能赋值给 never 类型,这就会产生一个编译错误。通过这种方式,你就可以确保 getArea 函数总是穷尽了所有 shape 的可能性。

TypeScript 系列

TypeScript 系列文章由官方文档翻译、重难点解析、实战技巧三个部分组成,涵盖入门、进阶、实战,旨在为你提供一个系统学习 TS 的教程,全系列预计 40 篇左右。点此浏览全系列文章,并建议顺便收藏站点。

微信:「mqyqingfeng」,加我进冴羽唯一的读者群。

如果有错误或者不严谨的地方,请务必给予指正,十分感谢。如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

好家伙 你终于更新了

commented
// both of these result in 'true'
Boolean("hello"); // type: boolean, value: true
!!"world"; // type: true,    value: true

!!"world" type 应该是 boolean

大佬回归!

现在不卷了吗=。=

现在不卷了吗=。=

哈哈,遇到老人了呀。第一卷就有你啦!

垂死梦中惊坐起!

好家伙 你终于更新了

@saltires 感谢不离不弃

// both of these result in 'true'
Boolean("hello"); // type: boolean, value: true
!!"world"; // type: true,    value: true

!!"world" type 应该是 boolean

@btea 你发现的不止是我的,还是官方文档的 bug

commented

@mqyqingfeng 哈哈,是这样吗?我还以为是你手误

@btea 快去给官方文档提 PR 吧

现在不卷了吗=。=

@jawil 实不相瞒,都被卷成咸鱼了,现在我要反卷回去了!

现在不卷了吗=。=

哈哈,遇到老人了呀。第一卷就有你啦!

@xiaoxin-sky 我们当时就是因为博客而认识,如今都已经过去六年,值得纪念 @jawil

垂死梦中惊坐起!

@huangsiyuan2015 垂死梦中惊坐起,缝缝补补又三年。咬定青山不放松,留取丹心照汗青。

好家伙,大佬终于更新了!

好家伙,大佬终于更新了!

@chenshaonian 接下来还会再多多更新几篇

强!👍🏻

非常详细,赞!

commented

@btea 快去给官方文档提 PR 吧

@mqyqingfeng 这里似乎是正确的,相关人员给的回答解释 https://www.typescriptlang.org/play#example/type-widening-and-narrowing

// both of these result in 'true'
Boolean("hello"); // type: boolean, value: true
!!"world"; // type: true,    value: true

!!"world" type 应该是 boolean

为啥我测试是boolean
image

commented
// both of these result in 'true'
Boolean("hello"); // type: boolean, value: true
!!"world"; // type: true,    value: true

!!"world" type 应该是 boolean

为啥我测试是boolean image

不是这个意思,ts 只是标注数据类型,并不改变数据类型,!!'a' 得到的值是 true , 数据类型当然是 boolean。上面说的类型是指 ts 根据值进行推导标记的类型,像这样

image

@btea 快去给官方文档提 PR 吧

@mqyqingfeng 这里似乎是正确的,相关人员给的回答解释 https://www.typescriptlang.org/play#example/type-widening-and-narrowing

确实是的,但他们也没有明确的用 const 呀……

commented

@btea 快去给官方文档提 PR 吧

@mqyqingfeng 这里似乎是正确的,相关人员给的回答解释 https://www.typescriptlang.org/play#example/type-widening-and-narrowing

确实是的,但他们也没有明确的用 const 呀……

对,如果用 let ,此时推导的变量类型是 boolean, 要是再加一个 if 判断,此时变量类型就会被收窄,ts 推导得到一个 true 的类型。这篇文章讲的是类型收窄,所以在这个地方没有描述更加详细,所以才造成了我们的疑惑吧...

image

react呢!!!!等了一年了呜呜呜

react呢!!!!等了一年了呜呜呜

@Baozhen-Yin 再等等,会有的

终于更新了,赞~

commented

---------------------------------- 假冒课代表来更新下摘要, 如有错误,还望见谅指正 ---------------------------------

narrowing

  • 是什么:TS的类型检查器会考虑到类型保护(typeof padding === number)和赋值语句,从而将类型推导为更精确类型的过程,称之为narrowing

内容:

  • typeof类型保护:typeof padding === number

  • 真值收窄:如果通过表达式判定为真值,则进行收窄。

  • 等值收窄:值相等,类型也完全相等

    • 同宽松相等(可用来:方便地判段两个值不是null也不是undefined
      if (value != null) {
        // no null and undefined
      }
  • in 操作符收窄:

    'swim' in animal
  • instanceOf收窄, 同typeOf

  • 赋值语句: 赋值语句的右值,正确的收窄左值

    let x:string | number
    x = 'cat' // x: string
  • 控制流分析: 不可达的代码不会进行类型分析。

    if (true) {
      x = 'Jess'
      return x
    }
    return x // NO
  • 类型判断式:类型的表明,声明,断言。

    cat as animal
    cat in animal
  • 可辨别联合:联合类型中的每个类型,包含了一个共同的字面量类型属性。TS会认为这是一个「可辨别联合」,对其进行收窄。

    interface cat {
      speak: 'miao'
    }
    interface dog {
      speak: 'wang'
    }
    
    switch(speak) {
      case 'wang':
        // ...
    }
  • never 穷尽检查:never可以赋值给任何类型,没有类型能赋值给never

    switch(type) {
      // ...
      default:
        const type:never = type
        // 如果没穷举完就会报错,可以用来判断是否穷尽了所有type的可能性
    }

可惜ts没有模式匹配,不然会更好用。

@JararvisQ 因为没有课代表,所以你就是课代表了~

图片加载失败了

当最一开始定义 radius 是 optional 的时候,我们会得到一个报错 (strickNullChecks 模式下),
因为 TypeScript 并不能判断出这个属性是一定存在的。

这里的 strickNullChecks 应该为 strictNullChecks

想请问下,如果传入的类型有多种不想在类型参数这里一个个写出来(dog | cat ...) ,但是又需要判断其中一部分特有的字段,有没有写法可以做到能让ts正确识别那些特有的属性。

Property 'radius' does not exist on type 'Shape'.
  Property 'radius' does not exist on type 'Square'.

没有填加 //

可辨别联合(Discriminated unions)很有用,一直受困于这种情况,现在知道怎么处理了

牛逼

类型判断式这里补充一下翻译:
"Any time isFish is called with some variable, TypeScript will narrow that variable to that specific type if the original type is compatible."
任何时候使用某个变量调用 isFish 时,如果原始类型兼容,TypeScript 都会将该变量缩小为该特定类型。
比如上述用例:如果该函数返回值为true, pet就会在后续使用过程中被推断为确切的Fish类型
https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates

commented

有个书写错误:
image
@mqyqingfeng

这个评论大佬可以分享一下怎么弄的嘛。。。。我也想要评论。。。

这一章一时半会儿感觉是学不明白了= =回头等深入使用以后再来看看。。