精通 TypeScript
请支持本书:购买捐赠
(广告,请勿屏蔽。)

22 类型守卫和断言函数



在 TypeScript 中,一个值可能具有对于某些操作来说过于宽泛的类型,例如联合类型。本章回答以下问题

22.1 静态类型何时过于宽泛?

要了解静态类型如何过于宽泛,请考虑以下函数 getScore()

assert.equal(
  getScore('*****'), 5);
assert.equal(
  getScore(3), 3);

getScore() 的框架如下所示

function getScore(value: number|string): number {
  // ···
}

getScore() 的函数体内,我们不知道 value 的类型是 number 还是 string。在我们知道之前,我们无法真正使用 value

22.1.1 通过 if 和类型守卫进行类型缩小

解决方案是在运行时通过 typeof 检查 value 的类型(第 A 行和第 B 行)

function getScore(value: number|string): number {
  if (typeof value === 'number') { // (A)
    // %inferred-type: number
    value;
    return value;
  }
  if (typeof value === 'string') { // (B)
    // %inferred-type: string
    value;
    return value.length;
  }
  throw new Error('Unsupported value: ' + value);
}

在本章中,我们将类型解释为值的集合。(有关此解释和其他解释的更多信息,请参阅 [内容未包含]。)

在从第 A 行和第 B 行开始的 then 块中,由于我们执行的检查,value 的静态类型发生了变化。我们现在正在使用原始类型 number|string 的子集。这种减少类型大小的方法称为缩小。检查 typeof 和类似运行时操作的结果称为类型守卫

请注意,缩小不会更改 value 的原始类型,它只是在我们通过更多检查时使其更具体。

22.1.2 通过 switch 和类型守卫进行类型缩小

如果我们使用 switch 而不是 if,缩小也适用

function getScore(value: number|string): number {
  switch (typeof value) {
    case 'number':
      // %inferred-type: number
      value;
      return value;
    case 'string':
      // %inferred-type: string
      value;
      return value.length;
    default:
      throw new Error('Unsupported value: ' + value);
  }
}

22.1.3 更多类型过于宽泛的情况

以下是更多类型过于宽泛的示例

请注意,这些类型都是联合类型!

22.1.4 unknown 类型

如果一个值具有 unknown 类型,我们几乎无法对其进行任何操作,必须先缩小其类型(第 A 行)

function parseStringLiteral(stringLiteral: string): string {
  const result: unknown = JSON.parse(stringLiteral);
  if (typeof result === 'string') { // (A)
    return result;
  }
  throw new Error('Not a string literal: ' + stringLiteral);
}

换句话说:unknown 类型过于宽泛,我们必须对其进行缩小。在某种程度上,unknown 也是一种联合类型(所有类型的联合)。

22.2 通过内置类型守卫进行类型缩小

正如我们所见,类型守卫是一种返回 truefalse 的操作,具体取决于其操作数在运行时是否满足某些条件。当结果为 true 时,TypeScript 的类型推断通过缩小操作数的静态类型来支持类型守卫。

22.2.1 严格相等 (===)

严格相等可以用作类型守卫

function func(value: unknown) {
  if (value === 'abc') {
    // %inferred-type: "abc"
    value;
  }
}

对于某些联合类型,我们可以使用 === 来区分其组成部分

interface Book {
  title: null | string;
  isbn: string;
}

function getTitle(book: Book) {
  if (book.title === null) {
    // %inferred-type: null
    book.title;
    return '(Untitled)';
  } else {
    // %inferred-type: string
    book.title;
    return book.title;
  }
}

使用 === 包含和 !=== 排除联合类型组成部分仅在该组成部分是单例类型(只有一个成员的集合)时才有效。类型 null 是一个单例类型。它的唯一成员是值 null

22.2.2 typeofinstanceofArray.isArray

这是三种常见的内置类型守卫

function func(value: Function|Date|number[]) {
  if (typeof value === 'function') {
    // %inferred-type: Function
    value;
  }

  if (value instanceof Date) {
    // %inferred-type: Date
    value;
  }

  if (Array.isArray(value)) {
    // %inferred-type: number[]
    value;
  }
}

请注意 value 的静态类型是如何在 then 块中被缩小的。

22.2.3 通过运算符 in 检查不同的属性

如果用于检查不同的属性,则运算符 in 是一个类型守卫

type FirstOrSecond =
  | {first: string}
  | {second: string};

function func(firstOrSecond: FirstOrSecond) {
  if ('second' in firstOrSecond) {
    // %inferred-type: { second: string; }
    firstOrSecond;
  }
}

请注意,以下检查将不起作用

function func(firstOrSecond: FirstOrSecond) {
  // @ts-expect-error: Property 'second' does not exist on
  // type 'FirstOrSecond'. [...]
  if (firstOrSecond.second !== undefined) {
    // ···
  }
}

在这种情况下,问题在于,如果没有类型缩小,我们就无法访问类型为 FirstOrSecond 的值的属性 .second

22.2.3.1 运算符 in 不会缩小非联合类型

唉,in 只对联合类型有帮助

function func(obj: object) {
  if ('name' in obj) {
    // %inferred-type: object
    obj;

    // @ts-expect-error: Property 'name' does not exist on type 'object'.
    obj.name;
  }
}

22.2.4 检查共享属性的值(可辨识联合)

在可辨识联合中,联合类型的组成部分具有一个或多个共同的属性,这些属性的值对于每个组成部分都不同。此类属性称为鉴别器

检查鉴别器的值是一个类型守卫

type Teacher = { kind: 'Teacher', teacherId: string };
type Student = { kind: 'Student', studentId: string };
type Attendee = Teacher | Student;

function getId(attendee: Attendee) {
  switch (attendee.kind) {
    case 'Teacher':
      // %inferred-type: { kind: "Teacher"; teacherId: string; }
      attendee;
      return attendee.teacherId;
    case 'Student':
      // %inferred-type: { kind: "Student"; studentId: string; }
      attendee;
      return attendee.studentId;
    default:
      throw new Error();
  }
}

在前面的示例中,.kind 是一个鉴别器:联合类型 Attendee 的每个组成部分都具有此属性,并且具有唯一的值。

if 语句和相等性检查的工作方式类似于 switch 语句

function getId(attendee: Attendee) {
  if (attendee.kind === 'Teacher') {
    // %inferred-type: { kind: "Teacher"; teacherId: string; }
    attendee;
    return attendee.teacherId;
  } else if (attendee.kind === 'Student') {
    // %inferred-type: { kind: "Student"; studentId: string; }
    attendee;
    return attendee.studentId;
  } else {
    throw new Error();
  }
}

22.2.5 缩小点符号名称的类型

我们还可以缩小属性的类型(甚至是通过属性名称链访问的嵌套属性)

type MyType = {
  prop?: number | string,
};
function func(arg: MyType) {
  if (typeof arg.prop === 'string') {
    // %inferred-type: string
    arg.prop; // (A)

    [].forEach((x) => {
      // %inferred-type: string | number | undefined
      arg.prop; // (B)
    });

    // %inferred-type: string
    arg.prop;

    arg = {};

    // %inferred-type: string | number | undefined
    arg.prop; // (C)
  }
}

让我们看一下前面代码中的几个位置

22.2.6 缩小数组元素类型

22.2.6.1 数组方法 .every() 不会进行类型缩小

如果我们使用 .every() 检查所有数组元素是否都为非空,则 TypeScript 不会缩小 mixedValues 的类型(第 A 行)

const mixedValues: ReadonlyArray<undefined|null|number> =
  [1, undefined, 2, null];

if (mixedValues.every(isNotNullish)) {
  // %inferred-type: readonly (number | null | undefined)[]
  mixedValues; // (A)
}

请注意,mixedValues 必须是只读的。如果不是,则对它的另一个引用将在静态上允许我们在 if 语句中将 null 推送到 mixedValues 中。但这会使 mixedValues 的缩小类型不正确。

前面的代码使用以下用户定义的类型守卫(稍后会详细介绍)

function isNotNullish<T>(value: T): value is NonNullable<T> { // (A)
  return value !== undefined && value !== null;
}

NonNullable<Union>(第 A 行)是 一个实用程序类型,它从联合类型 Union 中删除类型 undefinednull

22.2.6.2 数组方法 .filter() 生成类型更窄的数组

.filter() 生成类型更窄的数组(即,它实际上并没有缩小现有类型)

// %inferred-type: (number | null | undefined)[]
const mixedValues = [1, undefined, 2, null];

// %inferred-type: number[]
const numbers = mixedValues.filter(isNotNullish);

function isNotNullish<T>(value: T): value is NonNullable<T> { // (A)
  return value !== undefined && value !== null;
}

唉,我们必须直接使用类型守卫函数——带有类型守卫的箭头函数是不够的

// %inferred-type: (number | null | undefined)[]
const stillMixed1 = mixedValues.filter(
  x => x !== undefined && x !== null);

// %inferred-type: (number | null | undefined)[]
const stillMixed2 = mixedValues.filter(
  x => typeof x === 'number');

22.3 用户定义的类型守卫

TypeScript 允许我们定义自己的类型守卫,例如

function isFunction(value: unknown): value is Function {
  return typeof value === 'function';
}

返回类型 value is Function 是一个类型谓词。它是 isFunction() 类型签名的一部分

// %inferred-type: (value: unknown) => value is Function
isFunction;

用户定义的类型守卫必须始终返回布尔值。如果 isFunction(x) 返回 true,则 TypeScript 会将实际参数 x 的类型缩小为 Function

function func(arg: unknown) {
  if (isFunction(arg)) {
    // %inferred-type: Function
    arg; // type is narrowed
  }
}

请注意,TypeScript 并不关心我们如何计算用户定义的类型守卫的结果。这给了我们很大的自由度来选择我们使用的检查。例如,我们可以像下面这样实现 isFunction()

function isFunction(value: any): value is Function {
  try {
    value(); // (A)
    return true;
  } catch {
    return false;
  }
}

唉,我们必须对参数 value 使用类型 any,因为类型 unknown 不允许我们进行第 A 行中的函数调用。

22.3.1 用户定义的类型守卫示例:isArrayWithInstancesOf()

/**
 * This type guard for Arrays works similarly to `Array.isArray()`,
 * but also checks if all Array elements are instances of `T`.
 * As a consequence, the type of `arr` is narrowed to `Array<T>`
 * if this function returns `true`.
 * 
 * Warning: This type guard can make code unsafe – for example:
 * We could use another reference to `arr` to add an element whose
 * type is not `T`. Then `arr` doesn’t have the type `Array<T>`
 * anymore.
 */
function isArrayWithInstancesOf<T>(
  arr: any, Class: new (...args: any[])=>T)
  : arr is Array<T>
{
  if (!Array.isArray(arr)) {
    return false;
  }
  if (!arr.every(elem => elem instanceof Class)) {
    return false;
  }

  // %inferred-type: any[]
  arr; // (A)

  return true;
}

在第 A 行中,我们可以看到 arr 的推断类型不是 Array<T>,但我们的检查确保它当前是。这就是我们可以返回 true 的原因。TypeScript 相信我们,并在我们使用 isArrayWithInstancesOf() 时缩小为 Array<T>

const value: unknown = {};
if (isArrayWithInstancesOf(value, RegExp)) {
  // %inferred-type: RegExp[]
  value;
}

22.3.2 用户定义的类型守卫示例:isTypeof()

22.3.2.1 第一次尝试

这是在 TypeScript 中实现 typeof 的第一次尝试

/**
 * An implementation of the `typeof` operator.
 */
function isTypeof<T>(value: unknown, prim: T): value is T {
  if (prim === null) {
    return value === null;
  }
  return value !== null && (typeof prim) === (typeof value);
}

理想情况下,我们应该能够通过字符串(即 typeof 的结果之一)指定 value 的预期类型。但是那样我们就必须从该字符串派生类型 T,并且如何做到这一点并不立即明显(有一种方法,我们很快就会看到)。作为一种解决方法,我们通过 T 的成员 prim 指定 T

const value: unknown = {};
if (isTypeof(value, 123)) {
  // %inferred-type: number
  value;
}
22.3.2.2 使用重载

更好的解决方案是使用重载(省略了几种情况)

/**
 * A partial implementation of the `typeof` operator.
 */
function isTypeof(value: any, typeString: 'boolean'): value is boolean;
function isTypeof(value: any, typeString: 'number'): value is number;
function isTypeof(value: any, typeString: 'string'): value is string;
function isTypeof(value: any, typeString: string): boolean {
  return typeof value === typeString;
}

const value: unknown = {};
if (isTypeof(value, 'boolean')) {
  // %inferred-type: boolean
  value;
}

(这种方法是 Nick Fisher 的想法。)

22.3.2.3 使用接口作为类型映射

另一种方法是使用接口作为从字符串到类型的映射(省略了几种情况)

interface TypeMap {
  boolean: boolean;
  number: number;
  string: string;
}

/**
 * A partial implementation of the `typeof` operator.
 */
function isTypeof<T extends keyof TypeMap>(value: any, typeString: T)
: value is TypeMap[T] {
  return typeof value === typeString;
}

const value: unknown = {};
if (isTypeof(value, 'string')) {
  // %inferred-type: string
  value;
}

(这种方法是 Ran Lottem 的想法。)

22.4 断言函数

断言函数检查其参数是否满足某些条件,如果不满足则抛出异常。例如,许多语言都支持的一种断言函数是 assert()。如果布尔条件 condfalse,则 assert(cond) 会抛出异常。

在 Node.js 上,assert() 通过 内置模块 assert 支持。以下代码在第 A 行中使用了它

import assert from 'assert';
function removeFilenameExtension(filename: string) {
  const dotIndex = filename.lastIndexOf('.');
  assert(dotIndex >= 0); // (A)
  return filename.slice(0, dotIndex);
}

22.4.1 TypeScript 对断言函数的支持

如果我们将断言函数标记为返回类型的断言签名,则 TypeScript 的类型推断会为断言函数提供特殊支持。关于如何以及我们可以从函数返回什么,断言签名等效于 void。但是,它还会触发类型缩小。

断言签名有两种

22.4.2 断言布尔参数:asserts «cond»

在以下示例中,断言签名 asserts condition 指出参数 condition 必须为 true。否则,将抛出异常。

function assertTrue(condition: boolean): asserts condition {
  if (!condition) {
    throw new Error();
  }
}

这就是 assertTrue() 导致类型缩小的方式

function func(value: unknown) {
  assertTrue(value instanceof Set);

  // %inferred-type: Set<any>
  value;
}

我们使用参数 value instanceof Set 的方式类似于类型守卫,但不同之处在于,它不会跳过条件语句的一部分,而是当结果为 false 时触发异常。

22.4.3 断言参数的类型:asserts «arg» is «type»

在以下示例中,断言签名 asserts value is number 指明参数 value 必须具有 number 类型。否则,将抛出异常。

function assertIsNumber(value: any): asserts value is number {
  if (typeof value !== 'number') {
    throw new TypeError();
  }
}

这一次,调用断言函数会缩小其参数的类型

function func(value: unknown) {
  assertIsNumber(value);

  // %inferred-type: number
  value;
}
22.4.3.1 示例断言函数:向对象添加属性

函数 addXY() 向现有对象添加属性并相应地更新其类型

function addXY<T>(obj: T, x: number, y: number)
: asserts obj is (T & { x: number, y: number }) {
  // Adding properties via = would be more complicated...
  Object.assign(obj, {x, y});
}

const obj = { color: 'green' };
addXY(obj, 9, 4);

// %inferred-type: { color: string; } & { x: number; y: number; }
obj;

交集类型 S & T 同时具有类型 S 和类型 T 的属性。

22.5 快速参考:用户定义的类型守卫和断言函数

22.5.1 用户定义的类型守卫

function isString(value: unknown): value is string {
  return typeof value === 'string';
}

22.5.2 断言函数

22.5.2.1 断言签名:asserts «cond»
function assertTrue(condition: boolean): asserts condition {
  if (!condition) {
    throw new Error(); // assertion error
  }
}
22.5.2.2 断言签名:asserts «arg» is «type»
function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== 'string') {
    throw new Error(); // assertion error
  }
}

22.6 断言函数的替代方案

22.6.1 技术:强制转换

断言函数会缩小现有值的类型。强制转换函数返回具有新类型的现有值,例如

function forceNumber(value: unknown): number {
  if (typeof value !== 'number') {
    throw new TypeError();
  }
  return value;
}

const value1a: unknown = 123;
// %inferred-type: number
const value1b = forceNumber(value1a);

const value2: unknown = 'abc';
assert.throws(() => forceNumber(value2));

相应的断言函数如下所示

function assertIsNumber(value: unknown): asserts value is number {
  if (typeof value !== 'number') {
    throw new TypeError();
  }
}

const value1: unknown = 123;
assertIsNumber(value1);
// %inferred-type: number
value1;

const value2: unknown = 'abc';
assert.throws(() => assertIsNumber(value2));

强制转换是一种用途广泛的技术,其用途超出了断言函数的范围。例如,我们可以转换

有关更多信息,请参阅 [内容未包含]

22.6.2 技术:抛出异常

考虑以下代码

function getLengthOfValue(strMap: Map<string, string>, key: string)
: number {
  if (strMap.has(key)) {
    const value = strMap.get(key);

    // %inferred-type: string | undefined
    value; // before type check

    // We know that value can’t be `undefined`
    if (value === undefined) { // (A)
      throw new Error();
    }

    // %inferred-type: string
    value; // after type check

    return value.length;
  }
  return -1;
}

我们也可以使用断言函数来代替从 A 行开始的 if 语句

assertNotUndefined(value);

如果我们不想编写这样的函数,抛出异常是一种快速的替代方法。与调用断言函数类似,此技术也会更新静态类型。

22.7 @hqoss/guards:带有类型守卫的库

@hqoss/guards 为 TypeScript 提供了一系列类型守卫,例如