TypeScript基础之类型收窄(Type Narrowing)

前言

文中部分内容(typeof 、in、instanceof、类型谓词等)已经在TypeScript基础之类型保护介绍过。
文中内容都是官网https://www.typescriptlang.org/docs/handbook/2/narrowing.html 内容,以及参考
TypeScript 之 Narrowing—mqyqingfeng

类型收窄(Type Narrowing)

现在有一个名为padLeft的函数, 需要实现的功能是: 如果参数 padding 是一个数字,我们就在 input 前面添加同等数量的空格,而如果 padding 是一个字符串,我们就直接添加到 input 前面。
实现:

function padLeft(padding: number | string, input: string) {
  return "".repeat(padding) + input;
  // 类型“string | number”的参数不能赋给类型“number”的参数。
  // 不能将类型“string”分配给类型“number”。
}

如果这样写的话,编辑器里padding会提示类型“string | number”的参数不能赋给类型“number”的参数错误。 提示我们应该先检查下padding是否是一个number
所以下一步修改为:

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

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

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

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

function padLeft(padding: number | string, input: string) {
  if (typeof padding === "number") {
    return " ".repeat(padding) + input;
    // (parameter) padding: number
  }
  return padding + input;
  // (parameter) padding: string
}

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

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

typeof 类型保护(type guards)

关于typeof在之前文章里已经介绍过了,这里再介绍下。
​在 TypeScript 中,检查typeof返回的值就是一种类型保护。

function printAll(strs: string | string[] | null) {
  if (typeof strs === "object") {
    for (const s of strs) {
      // strs 提示 对象可能为 "null"。
      console.log(s);
    }
  } else if (typeof strs === "string") {
    console.log(strs);
  } else {
    // do nothing
  }
}

在这个 printAll 函数中,我们尝试判断 strs 是否是一个对象,原本的目的是判断它是否是一个数组类型,但是在 JavaScript 中,typeof null 也会返回 object。
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 会做隐式类型转换, 像空字符串、0、-0 、0n、NaN、null、undefined 和 false这些值都会被转为false, 其他则会被转为true。
你也可以使用Boolean函数强制转换为 boolean 值, 或者使用!!

Boolean("hello");  // true
!!"world";      // true

真值收窄这种使用方式非常流行,尤其适用于防范 null和 undefiend 这种值的时候。
如:

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);
  }
}

可以看到通过这种方式,成功的去除了错误。
但还是要注意,在基本类型上的真值检查很容易导致错误,比如:

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,就会进入错误的处理分支。

等值收窄(Equality narrowing)

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

function example(x: string | number, y: string | boolean) {
  if (x === y) {
    // We can now call any 'string' method on 'x' or 'y'.
    x.toUpperCase();
    // (method) String.toUpperCase(): string
    
    y.toLowerCase();
    // (method) String.toLowerCase(): string

  } else {
    console.log(x);
    // (parameter) x: string | number

    console.log(y);
    // (parameter) y: string | boolean
  }
}

以上代码中,我们判断了 x 和 y 是否完全相等,如果完全相等,那他们的类型肯定也完全相等。而 string 类型就是 x 和 y 唯一可能的相同类型。所以在第一个分支里,x 和 y 就一定是 string 类型。
判断具体的字面量值也能让 TypeScript 正确的判断类型。
我们知道: undefined == null 为true, 利用这点可以方便的判断一个值既不是null也不是undefined

interface Container {
  value: number | null | undefined;
}
function multiplyValue(container: Container, factor: number) {
  //  排除调null 、undefined
  if (container.value != null) {
    console.log(container.value);                           
    // (property) Container.value: number 

    container.value *= factor;
  }
}

in 操作符收窄

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

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
  }
}

以上代码里, Human里有swim、fly两个可选属性, TS通过in 操作符可以准备的进行类型收窄。

instanceof 收窄

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

function logValue(x: Date | string) {
  if (x instanceof Date) {
    console.log(x.toUTCString());        
    // (parameter) x: Date
  } else {
    console.log(x.toUpperCase());            
   // (parameter) x: string
  }
}

赋值语句(Assignments)

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

let x = Math.random() < 0.5 ? 10 : "hello world!"; 
// let x: string | number

x = 1;
console.log(x);        
// let x: number

x = "goodbye!"; 
console.log(x);        
// let x: string

以上赋值语句都有有效的,即便我们已经将 x 改为 number 类型,但我们依然可以将其更改为 string 类型,这是因为 x 最初的声明为 string | number,赋值的时候只会根据正式的声明进行核对。
所以如果我们把 x 赋值给一个 boolean 类型,就会报错

x = false;
// 不能将类型“false”分配给类型“string | number”

控制流分析(Control flow analysis)

来看看在 if while等条件控制语句中的类型保护, 如:

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

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

这种基于可达性(reachability) 的代码分析就叫做控制流分析(control flow analysis)。在遇到类型保护和赋值语句的时候,TypeScript 就是使用这样的方式收窄类型。

使用类型谓词(Using type predicates)

所谓 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 就可以将这个变量收窄到更具体的类型:

class Fish {
  swim () {
    console.log('游泳~');
  }
  eat () {
    console.log('进食!');
  }
}

class Bird {
  fly () {
    console.log('飞翔~');
  }
  eat () {
    console.log('进食!');
  }
}

function getSmallPet(): Fish | Bird {
  return Math.random() > 0.5 ? new Fish() : new Bird()
}
let pet = getSmallPet();

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

if (isFish(pet)) {
    pet.swim();     // let pet: Fish
} else {
    pet.fly();      // let pet: Bird
}

可辨别联合(Discriminated unions)

interface Circle {
  kind: "circle";  // 字符串字面量类型
  radius: number;
}
 
interface Square {
  kind: "square";   // 字符串字面量类型
  sideLength: number;
}

type Shape = Circle | Square;
function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2;   
  // 类型“Shape”上不存在属性“radius”。
  // 类型“Square”上不存在属性“radius”
}

报错,是因为 Shape 是一个联合类型,TypeScript 可以识别出 shape 也可能是一个 Square,而 Square 并没有 radius,所以会报错。
通过判断字面量类型来进行区分:

function getArea (shape: Shape) {
  switch (shape.kind) {
    case "circle":  // Circle类型
      return Math.PI * shape.radius ** 2;
    case "square":  // Square类型
      return shape.sideLength ** 2;
  }
}

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

never 类型

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

穷尽检查(Exhaustiveness checking)

never 类型可以赋值给任何类型,然而,没有类型可以赋值给 never (除了 never 自身)。这就意味着你可以在 switch 语句中使用 never 来做一个穷尽检查 .
接上面例子, 新添加一个新成员,却没有做对应处理的时候,就会导致一个 TypeScript 错误:

interface Triangle {
  kind: "triangle";
  sideLength: number;
}
type Shape = Circle | Square | Triangle;

function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle": // Circle类型
      return Math.PI * shape.radius ** 2;
    case "square": // Circle类型
      return shape.sideLength ** 2;
    default:
      const _exhaustiveCheck: never = shape; // 不能将类型“Triangle”分配给类型“never”
      return _exhaustiveCheck;
  }
}

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

参考资料

https://www.typescriptlang.org/docs/handbook/2/narrowing.html
TypeScript 之 Narrowing—mqyqingfeng

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值