TypeScript 之 Narrowing

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

4.png


前言


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


本篇整理自 www.typescriptlang.org/docs/handbo…


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


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)。 在编辑器中,我们可以观察到类型的改变:


01.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""0nnullundefined 这些值都会被转为 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 语句和等值检查比如 ==!====!= 去收窄类型。比如:


02.png


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


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


03.png


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


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


05.png


赋值语句(Assignments)


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


06.png


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


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


07.png


控制流分析(Control flow analysis)


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


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 就是使用这样的方式收窄类型。而使用这种方式,一个变量可以被观察到变为不同的类型:


08.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 属性检查一次呢?


09.png


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


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


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


这也适用于 switch 语句:


010.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,对作者也是一种鼓励。



目录
相关文章
|
3月前
|
JavaScript
typeScript进阶(9)_type类型别名
本文介绍了TypeScript中类型别名的概念和用法。类型别名使用`type`关键字定义,可以为现有类型起一个新的名字,使代码更加清晰易懂。文章通过具体示例展示了如何定义类型别名以及如何在函数中使用类型别名。
49 1
typeScript进阶(9)_type类型别名
|
2月前
|
JavaScript 前端开发 安全
深入理解TypeScript:增强JavaScript的类型安全性
【10月更文挑战第8天】深入理解TypeScript:增强JavaScript的类型安全性
64 0
|
2月前
|
JavaScript 前端开发 开发者
深入理解TypeScript:类型系统与实用技巧
【10月更文挑战第8天】深入理解TypeScript:类型系统与实用技巧
|
3月前
|
存储 JavaScript
typeScript进阶(11)_元组类型
本文介绍了TypeScript中的元组(Tuple)类型,它是一种特殊的数组类型,可以存储不同类型的元素。文章通过示例展示了如何声明元组类型以及如何给元组赋值。元组类型在定义时需要指定数组中每一项的类型,且在赋值时必须满足这些类型约束。此外,还探讨了如何给元组类型添加额外的元素,这些元素必须符合元组类型中定义的类型联合。
56 0
|
3月前
|
JavaScript
typeScript进阶(10)_字符串字面量类型
本文介绍了TypeScript中的字符串字面量类型,这种类型用来限制变量只能是某些特定的字符串字面量。通过使用`type`关键字声明,可以确保变量的值限定在预定义的字符串字面量集合中。文章通过示例代码展示了如何声明和使用字符串字面量类型,并说明了它在函数默认参数中的应用。
44 0
|
1月前
|
设计模式 JavaScript 安全
TypeScript性能优化及代码质量提升的重要性、方法与策略,包括合理使用类型注解、减少类型断言、优化模块导入导出、遵循编码规范、加强代码注释等
本文深入探讨了TypeScript性能优化及代码质量提升的重要性、方法与策略,包括合理使用类型注解、减少类型断言、优化模块导入导出、遵循编码规范、加强代码注释等,旨在帮助开发者在保证代码质量的同时,实现高效的性能优化,提升用户体验和项目稳定性。
46 6
|
1月前
|
开发框架 JavaScript 前端开发
TypeScript 是一种静态类型的编程语言,它扩展了 JavaScript,为 Web 开发带来了强大的类型系统、组件化开发支持、与主流框架的无缝集成、大型项目管理能力和提升开发体验等多方面优势
TypeScript 是一种静态类型的编程语言,它扩展了 JavaScript,为 Web 开发带来了强大的类型系统、组件化开发支持、与主流框架的无缝集成、大型项目管理能力和提升开发体验等多方面优势。通过明确的类型定义,TypeScript 能够在编码阶段发现潜在错误,提高代码质量;支持组件的清晰定义与复用,增强代码的可维护性;与 React、Vue 等框架结合,提供更佳的开发体验;适用于大型项目,优化代码结构和性能。随着 Web 技术的发展,TypeScript 的应用前景广阔,将继续引领 Web 开发的新趋势。
40 2
|
1月前
|
JavaScript 安全 前端开发
TypeScript类型声明:基础与进阶
通过本文的介绍,我们详细探讨了TypeScript的基础与进阶类型声明。从基本数据类型到复杂的泛型和高级类型,TypeScript提供了丰富的工具来确保代码的类型安全和可维护性。掌握这些类型声明能够帮助开发者编写更加健壮和高效的代码,提高开发效率和代码质量。希望本文能为您在使用TypeScript时提供实用的参考和指导。
39 2
|
1月前
|
JavaScript 开发者
在 Babel 插件中使用 TypeScript 类型
【10月更文挑战第23天】可以在 Babel 插件中更有效地使用 TypeScript 类型,提高插件的开发效率和质量,减少潜在的类型错误。同时,也有助于提升代码的可理解性和可维护性,使插件的功能更易于扩展和升级。
|
2月前
|
JavaScript 前端开发
TypeScript【类型别名、泛型】超简洁教程!再也不用看臭又长的TypeScript文档了!
【10月更文挑战第11天】TypeScript【类型别名、泛型】超简洁教程!再也不用看臭又长的TypeScript文档了!