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(A 行和 B 行)在執行階段檢查 value 的類型

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,具體取決於其運算元在執行階段是否符合特定條件。TypeScript 的類型推論支援類型防護,方法是在結果為 true 時縮小運算元的靜態類型。

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。當我們使用 isArrayWithInstancesOf() 時,TypeScript 信任我們並縮小為 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()assert(cond) 如果布林條件 condfalse,則擲回例外。

在 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 類型防護,例如