TypeScript之类型收缩(Narrowing)

823 阅读13分钟

假如我们有一个叫padLeft的函数:

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

如果paddingnumber类型,表示我们要输入的空格的数量。如果paddingstring类型,就应该是为input直接设置padding。现在让我们尝试去完善paddigLeft传递number类型的padding时的逻辑。

function padLeft(padding: number | string, input: string) {
  return " ".repeat(padding) + input;
 // Argument of type 'string | number' is not assignable to parameter of type 'number'.
 // Type 'string' is not assignable to type 'number'.	
}

哦吼,在padding的位置发生了一个错误。TypeScript警告我们将number | string赋值给number或许不能给我们想要的效果。换个角度来说,我们没有明确检查padding是一个数字类型,我们也没有对它是一个string类型时的情况进行处理,所以让我们完善这两点:

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

这段JavaScript代码看起来非常无趣,但是这就是重点。除了我们的类型注释之外,这段TypeScript就像是JavaScriptTypeScript的类型系统的目标是让书写JavaScript的代码尽可能的简单。

虽然看起来不多,但是展开了讲其实有很多东西。最多的是TypeScript怎么使用静态类型解析运行时的值,它涵盖了JavaScript运行时流程控制类型解析,比如if/else,三元表达式,循环,真值检查(truthiness check),等等这些都可以影响类型解析

if检查内部,TypeScript对于typeof padding === "number"这种特别的代码称为类型守卫(type guard)TypeScript以我们程序的分支结构为依据,来分析特定的位置应该将值解析为什么类型

TypeScript会查看这些特殊的检查(被称为type guard的检查),并且更进一步的来进行类型的推断,这种方式被称为类型收缩(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
}

TypeScript 可以理解几种不同的结构来缩小类型的范围。

typeof 类型守卫

正如我们所看见的,JavaScript支持typeof操作符,它可以在运行时提供关于值的非常基础的信息。TypeScrip通过typeof操作符来返回一个固定的string集合

  1. “string”
  2. “number”
  3. “bigint”
  4. “boolean”
  5. “symbol”
  6. “undefined”
  7. “object”
  8. “function”

现在让我们回到padLeft中,typeof运算符经常出现在许多 JavaScript 库中, 并且TypeScript可以明白它在不同的分支里是用来收缩类型的。

TypeScript中,检查typeof返回的值是一种类型保护。因为TypeScript可以解析typeof如何对不同的值进行操作,所以TypeScript也知道typeofJavaScript中的一些怪癖。举例来说,请注意上述列表当中,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是一个object类型,同时也会检查它是否是一个数组类型。但是事实证明在JavaScript中,typeof null实际上也返回了"object",这是段不幸的历史遗留。

拥有足够经验的开发者或许不会感到奇怪,但也不是每个人都会在JavaScript中遇到这个。还好的是strs被类型缩窄为string[] | null,代替了string[]。这可能是我们下面说的真值(truthiness)的一个很好的开始。

真值收缩(Truthiness narrowing)

真值或许你在字典中并找不到的单词,但是你在JavaScript会经常听到它。在JavaScript中,我们可以在条件中使用表达式----&&||if语句,布尔值的反向操作符 !,当然还有更多。来看一个例子,if语法没有强制他们的条件必须是boolean类型

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

JavaScript中,类似if的结构首先会强转它的条件变为boolean类型而来解析他们,然后靠判断是truefalse来选择分支。这些值:

  1. 0
  2. NaN
  3. ""(空字符串)
  4. 0n(bigint版本的0)
  5. null
  6. undefined

都会强制转换为false,并且不在上面的都会被强制转换为true,你可能一直用Boolean函数强制转换他们到boolean类型,但是也可以通过!!去操作(后者的优势是会被TypeScript推断为true类型)。

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

用这种的地方相当的多,尤其是来防止null或者undefined这种值。来看个例子,我们尝试在printAll函数中使用真值收缩(Truthiness narrowing):

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

你会注意到我们已经通过检查strs是否为消除了上面的错误。这至少可以防止我们在运行时出现可怕的错误:

TypeError: null is not iterable

使用真值检查的时候需要留意一些经常犯的错误。举个例子,再写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);
    }
  }
}

我们在方法体外再包装了一层真值检查,但是这有个小缺陷:之后会不再处理空字符串。

TypeScript 根本不会影响到我们,但是如果您对JavaScript不太熟悉,这是要关注的地方。TypeScript 通常可以帮助你及早发现错误,但如果你选择对值不做任何事情,

它可以做的只有这么多,而不是影响到代码。如果你想要处理这种情况,你可以使用 linter

最后,关于类型收缩的最后一个要点是!操作符过滤了否定分支

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

相等类型收缩(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
  }
}

在上述例子中,当我们通过检查xy这两个是相等的,TypeScript就明白他们的类型必定相等。string类型是xy唯一相同的值。TypeScript明白xy在第一个分支中必定是string类型。

检查特殊的字面量也可以起到相同的效果。在我们关于真值收缩的那一段,我们写了一个非常容易出错的printAll函数,因为它无法正确处理空字符串。我们可以使用一个特殊的检查来剔除null类型,之后TypeScript就能正确的移除来自strsnull类型了。

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

JavaScript使用更宽松的==!=等式检查也可以正确地缩小范围,如果你不是很熟悉的话,可以看一下==null实际上不仅检查了它是否是特别值null,也检查了是否是undefined。同样对于undefined

他检查了值是否是null或者undefined中的一个。

interface Container {
  value: number | null | undefined;
}
 
function multiplyValue(container: Container, factor: number) {
  // Remove both 'null' and 'undefined' from the type.
  if (container.value != null) {
    console.log(container.value);
                         //(property) Container.value: number
 
    // Now we can safely multiply 'container.value'.
    container.value *= factor;
  }
}

In操作符的类型收缩(The In operator narrowing)

JavaScript有一个通过属性名来检查object是否有某个属性的操作符:in。记住这个,TypeScriptin视为缩小潜在类型的一种方式。

举个例子,在"value" in x中,value是一个字面量类型,而x是一个联合类型。"true"的分支收缩了x的类型,x的类型变为有可选的属性value或者必选的属性value"false"的分支收缩x的类型为一个有可选value属性或者没有value属性的类型。

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

为了重现可选属性会出现在两个分支中,来举个例子,两个人都会游和飞,因此使用in时会在两个分支中出现Human

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 narrowing)

JavaScript有一个用来检查是否一个值是否是另一个值的实体。说到更仔细点,在JavaScript中使用x instanceof Foo来检查是否x原项链prototype chain)包含Foo.prototype。你可能已经猜到了,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。这是主要是x的类型声明的原因 ---- 这个x开始是string | number类型,同时TypeScript始终根据声明的类型检查可分配的类型值

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

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

x = true;
// Type 'boolean' is not assignable to type 'string | number'.
 
console.log(x);        
// let x: string | number

控制流解析(Control flow analysis)

到此为止,我们已经看到了一些关于 TypeScript如何在特定分支中缩小类型f的例子,但是,除了从变量在 ifwhile条件语句中实现类型保护之外,还有其它重要方式可以做到。例如:

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

padLeft从它的第一个if块中返回。TypeScript能够分析此代码并发现在paddingnumber类型的情况下,正文的其余部分(return padding + input)是无法访问到的。最终得到的是,在函数的剩余部分TypeScript能够从padding从string | number收缩成string类型)的类型中删除number类型.

这种基于可达性的代码分析称之为流程控制解析(Control flow analysis)TypeScript在遇到类型保护赋值时会使用这种流程分析来缩小类型。当解析一个变量的类型时,分析流程控制可以一次又一次地拆分和合并类型,同时可以看到该变量在每个分支下具有不同的类型。

function example() {
  let x: string | number | boolean;
 
  x = Math.random() < 0.5;
 
  console.log(x);
             //let x: boolean
 
  if (Math.random() < 0.5) {
    x = "hello";
    console.log(x);
            // let x: string
  } else {
    x = 100;
    console.log(x);              
              // let x: number
  }
 
  return x;
       // let x: string | number
}

使用类型断言(Using type predicates)

到目前为止,我们已经使用现有的JavaScript结构来缩小范围,但是有时你想要更直接地控制类型在整个代码中的变化方式。

这就需要要定义一个自定义的类型保护(user-defined type guard),其实我们只需要简单的定义一个返回类型类型断言函数

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();
} else {
  pet.fly();
}

请注意,TypeScript不仅知道在if分支中petFish类型;它也知道在else分支中你不可能是Fish类型,你肯定是Bird类型。

你可能会使用类型守卫isFinsh来过滤一个Fish | 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[];
 
// The predicate may need repeating for more complex examples
const underWater3: Fish[] = zoo.filter((pet): pet is Fish => {
  if (pet.name === "sharkey") return false;
  return isFish(pet);
});

拆分联合类型(Discriminated unions)

到目前位置,我们看到的大多数实例都集中在使用简单类型string,boolean,number来缩小单个变量的范围。虽然这很常见,但大多数时候在JavaScript中我们会遇到处理稍微复杂结构的情况。

为了实现某些目的,假设我们正在尝试对圆形和正方形等形状进行编码,记录它们的半径,正方形记录它们的边长。我们使用一个kind字段来区分我们要处理的形状。这里开始第一次尝试来定义Shape

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

请注意我们使用了一个由字面量组成的联合类型:“circle”“square”,从而来告诉我们应该将形状分别视为圆形正方形。通过使用"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函数来调用正确的逻辑来处理圆形或者正方形。我们来对圆形进行第一次处理:

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

在开启strictNullChecks情况下,我们得到了一个错误----这是合理的,因为半径可能未定义。但是如果我们对kind属性进行适当的检查可以防止错误发生吗?

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

嗯~ o( ̄▽ ̄)o,TypeScript仍然不知道应该去做什么。我们已经达到了比类型检查器更了解我们的值需要什么类型的地步。我们可以尝试使用非空断言(在shap.radius后面放!号)来告诉radius是肯定存在的。

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

不过这样看起来并不完美。我们不得不用非空断言来说服类型检查,表明shap.radius已定义,但如果之后我们开始修改代码,这些断言很容易出错。如果关闭strictNullChecks,那我们可以随意的访问这些字段中的任何一个(因为可选属性在假定读取它们的时候始终存在)。但是我们绝对可以做得更好。

现在的问题是Shape类型的编码使得类型检查器无法通过任何方式去基于Kind属性来知道是否有radius或者sideLength。我们需要用我们已经学到的东西和类型检查器来进行交流。处于这个目的,让我们重新来定义Shape类型。

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

现在,我们重新划分Shape类型成为两个Kind属性不同的类型。但是radiusslideLength被定义成了必须的在它们各自的类型当中。

让我们看看现在访问Shaperadius属性会发生点什么:

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类型一样,这里仍然有一个错误。当radius

是可选的,我们拿到了一个错误(在strictNullChecks开启的情况下),因为TypeScript无法判断改属性是否存在。到现在为止,Shape是一个联合类型,TypeScript告诉我们shape

或许是Square类型,并且Square中没有radius的定义。两种解释都是正确的,但无论strictNullChecks是如何配置的,Shape的并集类型解析都会导致错误。

但是如果我们尝试再一次检查kind属性呢?

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

这样就摆脱了错误!当联合类型中的每个类型都包含具有文字类型的公共属性时,TypeScript认为这是一个能拆分的联合类型(discriminated unio),并且可以缩小联合类型到其成员类型

这样做之后,Kind称为了共有的属性,从而将形状缩小为圆形。

相同的检查工作使用switch语句表现的也非常好。现在我们去不通过!断言来完善getArea

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

可以看出来Shape的编码是非常重要的一件事情。和TypeScript交流正确的信息--Circle类型和Square类型通过Kind字段进行了划分--这些是至关重要的。做这些事情让我们写出了类型安全的TypeScript

代码,并且让它看起来和JavaScript的编码没有区别。这样之后,类型系统能够做“正确”的事情,并找出我们switch语句的每个分支中的类型。

拆分联合类型不仅仅对于圆形正方形来说是非常有用的。它们也非常适合在JavaScript中表示任何类型的消息传递方案,比如通过网络发送消息(客户端/服务器通信),或者可以在状态管理框架中编写灵活的代码。

never类型(The never type)

缩小范围时,你可以将联合的成员缩小到消除所有的可能性为止。最后TypeScript将使用never类型来表示不应该存在的状态。

穷举检查(Exhaustiveness checking)

never类型能赋予任何类型,然而,没有任何类型可以赋值给never类型(除了never本身)。并且可以使用switch语句去做穷举检查,来收缩到never类型。

举个例子,添加default到我们的getArea函数中,getArea函数在处理所有的情况之后,会尝试赋值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;
  }
}

参考 Narrowing