likes
comments
collection
share

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

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

前言

文中部分内容(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