深入理解 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 是一个值,而不是一个类型。类型运算符 typeofEOF 转换为类型。有关值和类型的不同语言级别的更多信息,请参阅 §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
  }
}