关于javascript:TypeScript-之-Narrowing

65次阅读

共计 9656 个字符,预计需要花费 25 分钟才能阅读完成。

前言

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)。在编辑器中,咱们能够察看到类型的扭转:



从上图中能够看到在 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 语句和等值查看比方 == !== == != 去收窄类型。比方:

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

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

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

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 正确的类型收窄:

赋值语句(Assignments)

TypeScript 能够依据赋值语句的右值,正确的收窄左值。

留神这些赋值语句都有无效的,即使咱们曾经将 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 就是应用这样的形式收窄类型。而应用这种形式,一个变量能够被察看到变为不同的类型:

类型判断式(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 属性查看一次呢?

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

当联结类型中的每个类型,都蕴含了一个独特的字面量类型的属性,TypeScript 就会认为这是一个 可分别联结(discriminated union),而后能够将具体成员的类型进行收窄。

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

这也实用于 switch 语句:

这里的要害就在于如何定义 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 系列

冴羽的全系列文章地址:https://github.com/mqyqingfeng/Blog

TypeScript 系列是一个我都不晓得要写什么的系列文章,如果你对于 TypeScript 有什么困惑或者想要理解的内容,欢送与我交换,微信:「mqyqingfeng」,公众号:「冴羽的 JavaScript 博客」或者「yayujs」

如果有谬误或者不谨严的中央,请务必给予斧正,非常感激。如果喜爱或者有所启发,欢送 star,对作者也是一种激励。

正文完
 0