Skip to content

类型收缩(收敛/收窄/缩小)

为什么需要类型收缩(收敛/收窄/缩小)?

假设我们有一个名为 padLeft 的函数。

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

如果 padding 是一个数字,它会将其视为我们想要在 input 前面添加的空格数。

如果 padding 是一个字符串,它应该只在 input 前面加上 padding。让我们尝试实现 padLeft 何时传递数字进行填充的逻辑。

typescript
function padLeft(padding: number | string, input: string): 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'.
function padLeft(padding: number | string, input: string): 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'.

我们在填充时遇到错误。TypeScript 警告我们,我们正在将一个类型为 number |string 的值传递给 repeat 函数,该函数只接受一个数字,这是正确的。换句话说,我们没有首先显式检查填充是否为数字,也没有处理它是字符串的情况,所以让我们这样做。

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

TypeScript 的类型系统旨在使编写典型的 JavaScript 代码变得尽可能简单,而无需向后弯曲以获得类型安全。

TypeScript 使用静态类型分析运行时值一样,它将类型分析叠加在 JavaScript 的运行时控制流结构上,如 if/else条件三元循环真值检查等,这些都可能影响这些类型。

在我们的 if 检查中,TypeScript 看到 typeof padding === “number” 并将其理解为一种称为类型守卫的特殊代码形式。TypeScript 遵循我们的程序可以采用的可能执行路径来分析给定位置最具体的可能值类型。它着眼于这些特殊检查(称为类型保护)和赋值,将类型细化为比声明的更具体的类型的过程称为 narrowing。在许多编辑器中,我们可以观察这些类型的变化,我们甚至会在示例中这样做。

类型守卫(typeof)

正如我们所看到的TypeScript,和JavaScript一样 支持 typeof 运算符

类型结果
Undefined"undefined"
Null"object"
Boolean"boolean"
Number"number"
String"string"
BigInt"bigint"
Symbol"symbol"
Function"function"
其他任何对象"object"

typeof 能判断类型的所有结果:9情况,8种返回值。

在 JavaScript 中,typeof null 实际上是 “object”!这是历史上不幸的事故之一。

利用此行为相当流行,尤其是用于防范 null 或 undefined 等值。例如,让我们尝试将它用于 printAll 函数。

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

真实性收缩

6 种值可以通过 primitives 被转换成 false(如下表),其他都会被转换成 true。

参数类型0 / 0n" "NaNNullfalseUndefined
结果falsefalsefalsefalsefalsefalse
typescript
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) {
  if (strs && typeof strs === "object") { 
    for (const s of strs) {
      console.log(s);
    }
  } else if (typeof strs === "string") {
    console.log(strs);
  }
}

上面的 if 中 strs 通过 primitives 从string | string[] | null 收缩为 string[] | null,避免了 TypeError: null is not iterable

但请记住,对 primitives 的真度检查通常容易出错。编写 printAll 的不同尝试:
typescript
function printAll(strs: string | string[] | null) {
  if (strs) {
    if (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) {
  if (strs) {
    if (typeof strs === "object") {
      for (const s of strs) {
        console.log(s);
      }
    } else if (typeof strs === "string") {
      console.log(strs);
    }
  }
}

关于按真度缩小范围的最后一句话是,带有! 的布尔否定从否定的分支中过滤出来。

typescript
if (!values) {
  // do...
}
if (!values) {
  // do...
}

相等性收缩

TypeScript 还使用 switch 语句和相等性检查(如 ===!====!=)来缩小类型范围。

typescript
function example(x: string | number, y: string | boolean) {
  if (x === y) {
    x.toUpperCase(); // String.toUpperCase(): string
    y.toLowerCase(); // String.toLowerCase(): string
  } else {
    console.log(x);  // (parameter) x: string | number
    console.log(y);  // (parameter) y: string | boolean
  }
}
function example(x: string | number, y: string | boolean) {
  if (x === y) {
    x.toUpperCase(); // String.toUpperCase(): string
    y.toLowerCase(); // String.toLowerCase(): string
  } else {
    console.log(x);  // (parameter) x: string | number
    console.log(y);  // (parameter) y: string | boolean
  }
}

当我们在上面的例子中检查 x 和 y 都相等时,TypeScript 知道它们的类型也必须相等。由于 string 是 x 和 y 都可以采用的唯一常见类型,因此 TypeScript 知道 x 和 y 必须是第一个分支中的字符串s。

检查特定的 Literals 值(而不是变量)也有效。在我们关于真度缩小的部分中,我们编写了一个 printAll 函数,该函数很容易出错,因为它不小心没有正确处理空字符串。相反,我们可以做一个特定的检查来阻止 nulls,而 TypeScript 仍然正确地从 str 类型中删除 null。
typescript
function printAll(strs: string | string[] | null) {
  if (strs !== null) {
    if (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) {
  if (strs !== null) {
    if (typeof strs === "object") {
      for (const s of strs) {
        console.log(s);
      }
    } else if (typeof strs === "string") {
      console.log(strs);      
    }
  }
}

JavaScript 中较松散的 ==!= 相等性检查也可以正确缩小范围。如果你不熟悉,检查某个东西是否 == null 实际上不仅检查它是否是特定的值 null,它还检查它是否可能是未定义的。这同样适用于 == undefined:它检查值是 null 还是 undefined。

typescript
interface Container {
  value: number | null | undefined;
}
function multiplyValue(container: Container, factor: number) {
  if (container.value != null) {  // 通过 != null 收缩 为 number类型
    console.log(container.value);
    container.value *= factor;
  }
}
interface Container {
  value: number | null | undefined;
}
function multiplyValue(container: Container, factor: number) {
  if (container.value != null) {  // 通过 != null 收缩 为 number类型
    console.log(container.value);
    container.value *= factor;
  }
}

in 运算符收缩

JavaScript 有一个运算符,用于确定对象或其原型链是否具有具有名称的属性:in 运算符。TypeScript 将此作为缩小潜在类型范围的一种方式。

用途: 检查对象 是否拥有某个属性(包括自身属性和原型链继承的属性)。

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

instanceof 运算符收缩

JavaScript 有一个运算符,用于检查一个值是否是另一个值的 “实例”。更具体地说,在 JavaScript 中,x instanceof Foo 检查 x 的原型链是否包含 Foo.prototype

用途: 检查对象 是否是某个构造函数的实例(即检查原型链)。

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

注意

in:即使属性值为 undefined,in 仍返回 true,应为它检测的是属性是不否存在(都会在原型链上找)。

instanceof:右侧必须为函数,否则抛出 TypeError,用于 检查对象类型(都会在原型链上找)。

在需要严格类型检测时,考虑结合 Object.prototype.toString.call(obj) 或静态方法(如 Array.isArray())。

赋值分配收缩

正如我们之前提到的,当我们分配给任何变量时,TypeScript 会查看赋值的右侧并适当地缩小左侧的范围。

typescript
let x = Math.random() < 0.5 ? 10 : "hello world!";
x = 1;
console.log(x); // 上面赋值,类型收缩为number
x = "goodbye!";
console.log(x); // 上面赋值,类型收缩为string
let x = Math.random() < 0.5 ? 10 : "hello world!";
x = 1;
console.log(x); // 上面赋值,类型收缩为number
x = "goodbye!";
console.log(x); // 上面赋值,类型收缩为string

请注意,这些赋值中的每一个都是有效的。尽管在第一次分配后观察到的 x 类型更改为数字,但我们仍然能够为 x 分配一个字符串。这是因为 x 的声明类型(x 开头的类型)是 string | number,并且始终根据声明的类型检查可分配性。

控制流收缩

除了从每个变量中向上走动并在 ifs、whiles、条件语句等中寻找类型守卫之外,还有更多的事情要做。例如:

typescript
function padLeft(padding: number | string, input: string) {
  if (typeof padding === "number") {
    return " ".repeat(padding) + input;
  }
  return padding + input;   // 这里因为上面的if 被收缩为 string
}
function padLeft(padding: number | string, input: string) {
  if (typeof padding === "number") {
    return " ".repeat(padding) + input;
  }
  return padding + input;   // 这里因为上面的if 被收缩为 string
}

padLeft 从其第一个 if 块中返回。TypeScript 能够分析此代码,并看到在 padding 为数字的情况下,正文的其余部分(return padding + input;)是无法访问的。因此,它能够从函数其余部分的填充类型中删除 number(从 string | number 缩小到 string)。

这种基于可访问性的代码分析称为控制流分析,TypeScript 在遇到类型保护和赋值时使用此流分析来缩小类型范围。当分析变量时,控制流可以一遍又一遍地拆分和重新合并,并且可以观察到该变量在每个点具有不同的类型。

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

类型谓词 is

要定义用户定义的类型守卫,我们只需要定义一个返回类型为类型谓词的函数:

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

谓词采用 parameterName is Type 格式,其中 parameterName 必须是当前函数签名中的参数名称。

typescript
let pet = getSmallPet();
if (isFish(pet)) {
  pet.swim();
} else {
  pet.fly();
}
let pet = getSmallPet();
if (isFish(pet)) {
  pet.swim();
} else {
  pet.fly();
}

每当使用某个变量调用 isFish 时,如果原始类型兼容,TypeScript 就会将该变量缩小到该特定类型。

你可以使用类型守卫 isFish 来过滤 Fish |Bird 并获取 Fish 数组:

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

断言函数

也可以使用 Assertion 函数缩小类型范围。

typescript
function assert(condition: any, msg?: string): asserts condition {
  if (!condition) {
    throw new AssertionError(msg);
  }
}
function assert(condition: any, msg?: string): asserts condition {
  if (!condition) {
    throw new AssertionError(msg);
  }
}

Discriminated Unions

TypeScript 提供了一种建模方式,能够去区分处于多种不同状态的类型,这种判别模式就叫作 Discriminated Unions(高级联合类型)。

让我们想象一下我们正在尝试对圆形和正方形等形状进行编码。圆跟踪它们的半径,正方形跟踪它们的边长。我们将使用一个名为 kind 的字段来判断我们正在处理哪个形状。这是定义 Shape 的第一次尝试。

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

function handleShape(shape: Shape) {
  if (shape.kind === "rect") { 
    // ...
  }
}
// 报错:This comparison appears to be unintentional because the types '"circle" | "square"' and '"rect"' have no overlap.
// 因为:shape.kind 没有定义 “rect”类型
interface Shape {
  kind: 'circle' | 'square';
  radius?: number;
  sideLength?: number;
}

function handleShape(shape: Shape) {
  if (shape.kind === "rect") { 
    // ...
  }
}
// 报错:This comparison appears to be unintentional because the types '"circle" | "square"' and '"rect"' have no overlap.
// 因为:shape.kind 没有定义 “rect”类型

请注意,我们使用字符串文本类型的联合:“circle” 和 “square” 来告诉我们应该分别将形状视为圆形还是方形。通过使用 “circle” |“square” 而不是 string,我们可以避免拼写错误的问题。

我们可以编写一个 getArea 函数,该函数根据它是处理圆形还是方形来应用正确的逻辑。我们首先尝试处理圆圈:

typescript
function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2;
}
// 报错:'shape.radius' is possibly 'undefined'.
// 因为:可能没有定义 radius。
function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2;
}
// 报错:'shape.radius' is possibly 'undefined'.
// 因为:可能没有定义 radius。

我们对 kind 属性执行适当的检查:

typescript
function getArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius ** 2;
  }
}
// 报错:'shape.radius' is possibly 'undefined'.
// 因为:虽然对形状的类型做了类型收缩, shape.radius 任然可能为空。
function getArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius ** 2;
  }
}
// 报错:'shape.radius' is possibly 'undefined'.
// 因为:虽然对形状的类型做了类型收缩, shape.radius 任然可能为空。

我们可以尝试使用非 null 断言(shape.radius 后面的!,非空断言)来表示 radius 肯定存在,来解决这一问题。

我们应该尽可能的减少,非空断言的使用,因为这并不是十分安全的行为。

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

虽然我们勉强可以解决这个问题,但这种 Shape 编码的问题在于,类型检查器无法根据 kind 属性知道 radius 或 sideLength 是否存在。我们需要将我们所知道的传达给类型检查器。考虑到这一点,让我们再来定义一下 Shape。

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

我们尝试访问 Shape 的面积。

typescript
function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2;
}
// 启用了 strictNullChecks 的情况下任然会报错
// 报错:Property 'radius' does not exist on type 'Shape' .
// 报错:Property 'radius' does not exist on type 'Square'.
// 因为:因为 TypeScript 无法判断 radius 该属性是否存在。现在 Shape 是一个联合类型 Circle | Square; Square没有定义半径
// 但只有 Shape 的联合编码会导致错误,无论 strictNullChecks 的配置方式如何。

function getArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius ** 2;
  }else{
    return Math.PI * shape.sideLength ** 2;
  }
}
// 只需添加一个 shape.kind 检查,将 shape 类型收缩
function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2;
}
// 启用了 strictNullChecks 的情况下任然会报错
// 报错:Property 'radius' does not exist on type 'Shape' .
// 报错:Property 'radius' does not exist on type 'Square'.
// 因为:因为 TypeScript 无法判断 radius 该属性是否存在。现在 Shape 是一个联合类型 Circle | Square; Square没有定义半径
// 但只有 Shape 的联合编码会导致错误,无论 strictNullChecks 的配置方式如何。

function getArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius ** 2;
  }else{
    return Math.PI * shape.sideLength ** 2;
  }
}
// 只需添加一个 shape.kind 检查,将 shape 类型收缩

同样的检查也适用于 switch 语句。现在我们可以尝试编写完整的 getArea,而没有任何讨厌的 !非空断言。

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

它们非常适合在 JavaScript 中表示任何类型的消息传递方案,例如通过网络发送消息(客户端/服务器通信)或在状态管理框架中编码变化时。

never

缩小范围时,您可以将联合的选项减少到已删除所有可能性并且一无所有的程度。在这些情况下,TypeScript 将使用 never 类型来表示不应该存在的状态

穷举

never 类型可分配给每个类型;但是,没有类型可以分配给 never(除了 never 本身)。这意味着你可以使用 narrowing 并依赖于 never up 在 switch 语句中进行详尽的检查。