TypeScript 應對之道
請支持這本書:購買捐贈
(廣告,請不要封鎖。)

14 新增類型特殊值



了解類型的其中一種方式是將其視為值集合。有時會有兩個層級的值

在本章中,我們將探討如何將特殊值新增至基本層級類型。

14.1 新增頻帶內特殊值

新增特殊值的一種方式是建立一個新類型,此類型是基本類型的超集,其中某些值為特殊值。這些特殊值稱為哨兵。它們存在於頻帶內(想像在同一個頻道內),作為一般值的同類項。

舉例來說,考量下列可讀取串流的介面

interface InputStream {
  getNextLine(): string;
}

目前,.getNextLine() 只處理文字列,但不處理檔案結尾 (EOF)。我們如何新增對 EOF 的支援?

可能性包括

接下來的兩個小節說明我們可以引入哨兵值的兩種方式。

14.1.1 新增 nullundefined 至類型

在使用嚴格的 TypeScript 時,沒有任何簡單的物件類型(透過介面、物件模式、類別等定義)包含 null。這使其成為一個良好的哨兵值,我們可以透過聯合類型將其新增至基本類型 string

type StreamValue = null | string;

interface InputStream {
  getNextLine(): StreamValue;
}

現在,每當我們使用 .getNextLine() 回傳的值時,TypeScript 會強制我們考量兩種可能性:字串和 null – 例如

function countComments(is: InputStream) {
  let commentCount = 0;
  while (true) {
    const line = is.getNextLine();
    // @ts-expect-error: Object is possibly 'null'.(2531)
    if (line.startsWith('#')) { // (A)
      commentCount++;
    }
    if (line === null) break;
  }
  return commentCount;
}

在 A 行中,我們無法使用字串方法 .startsWith(),因為 line 可能為 null。我們可以透過下列方式修正此問題

function countComments(is: InputStream) {
  let commentCount = 0;
  while (true) {
    const line = is.getNextLine();
    if (line === null) break;
    if (line.startsWith('#')) { // (A)
      commentCount++;
    }
  }
  return commentCount;
}

現在,當執行到達 A 行時,我們可以確定 line 不為 null

14.1.2 新增符號至類型

我們也可以使用除了 null 以外的值作為哨兵。符號和物件最適合這個任務,因為它們每一個都有唯一的識別碼,不會有其他值被誤認為它。

以下是使用符號表示 EOF 的方法

const EOF = Symbol('EOF');
type StreamValue = typeof EOF | string;

為什麼我們需要 typeof 而不能直接使用 EOF?那是因為 EOF 是值,而不是類型。類型運算子 typeof 會將 EOF 轉換為類型。有關值和類型的不同語言層級的詳細資訊,請參閱 §7.7「兩個語言層級:動態與靜態」

14.2 加入頻段外特殊值

如果方法可能會傳回任何值,我們該怎麼辦?我們要如何確保基本值和元值不會混淆?以下是一個可能發生混淆的範例

interface InputStream<T> {
  getNextValue(): T;
}

無論我們為 EOF 選擇什麼值,都存在有人建立 InputStream<typeof EOF> 並將該值加入串流的風險。

解決方案是將一般值和特殊值分開,這樣它們就不會混淆。特殊值獨立存在稱為 頻段外(想像成不同的頻道)。

14.2.1 區分聯合

區分聯合 是聯合類型,包含多個物件類型,而這些物件類型至少有一個共通的屬性,稱為判別式。判別式必須對每個物件類型有不同的值,我們可以將它視為物件類型的 ID。

14.2.1.1 範例:InputStreamValue

在以下範例中,InputStreamValue<T> 是區分聯合,而它的判別式是 .type

interface NormalValue<T> {
  type: 'normal'; // string literal type
  data: T;
}
interface Eof {
  type: 'eof'; // string literal type
}
type InputStreamValue<T> = Eof | NormalValue<T>;

interface InputStream<T> {
  getNextValue(): InputStreamValue<T>;
}
function countValues<T>(is: InputStream<T>, data: T) {
  let valueCount = 0;
  while (true) {
    // %inferred-type: Eof | NormalValue<T>
    const value = is.getNextValue(); // (A)

    if (value.type === 'eof') break;

    // %inferred-type: NormalValue<T>
    value; // (B)

    if (value.data === data) { // (C)
      valueCount++;
    }
  }
  return valueCount;
}

最初,value 的類型是 InputStreamValue<T>(A 行)。然後我們排除判別式 .type 的值 'eof',而它的類型縮小為 NormalValue<T>(B 行)。這就是為什麼我們可以在 C 行存取屬性 .data

14.2.1.2 範例:IteratorResult

在決定如何實作 反覆運算器 時,TC39 不想使用固定的哨兵值。否則,該值可能會出現在可反覆運算的物件中並中斷程式碼。一種解決方案是在開始反覆運算時選擇哨兵值。TC39 反而選擇使用具有共通屬性 .done 的區分聯合

interface IteratorYieldResult<TYield> {
  done?: false; // boolean literal type
  value: TYield;
}

interface IteratorReturnResult<TReturn> {
  done: true; // boolean literal type
  value: TReturn;
}

type IteratorResult<T, TReturn = any> =
  | IteratorYieldResult<T>
  | IteratorReturnResult<TReturn>;

14.2.2 其他類型的聯合類型

其他類型的聯合類型可以像辨別聯合一樣方便,只要我們有辦法區分聯合的成員類型。

一種可能性是透過獨特屬性來區分成員類型

interface A {
  one: number;
  two: number;
}
interface B {
  three: number;
  four: number;
}
type Union = A | B;

function func(x: Union) {
  // @ts-expect-error: Property 'two' does not exist on type 'Union'.
  // Property 'two' does not exist on type 'B'.(2339)
  console.log(x.two); // error
  
  if ('one' in x) { // discriminating check
    console.log(x.two); // OK
  }
}

另一種可能性是透過 typeof 和/或實例檢查來區分成員類型

type Union = [string] | number;

function logHexValue(x: Union) {
  if (Array.isArray(x)) { // discriminating check
    console.log(x[0]); // OK
  } else {
    console.log(x.toString(16)); // OK
  }
}