TS 中的 Discriminated Unions

TypeScript 最强大的特性之一就是就是它提供了一种建模方式,能够去区分处于多种不同状态的类型,这种判别模式就叫作 Discriminated Unions

先来看一个不好的设计

比如我们通过 xhr 获取数据,那么就可能会存在三种状态:请求中/请求成功/请求失败, 我们可以通过一个状态字段去表示

type State = {
  status: "loading" | "success" | "error";
};

看起来很完美,并没有什么问题,但是除了状态信息,我们还需要更多的内容,比如如果请求成功,我们需要拿到请求的数据,如果请求失败,则要拿到失败的信息。

这些信息都是可能存在也可能不存在,所以它们是 Optional Properties 。

type State = {
  status: "loading" | "success" | "error";
  error?: string;
  data?: string;
};

然后我们会有地方去消费这些数据,假设我们把它显示在视图上

const renderUI = (state: State) => {
  if (state.status === "loading") {
    return "Loading...";
  }
 
  if (state.status === "error") {
    return `Error: ${state.error.toUpperCase()}`;
//                   ^^^^^^^^^^^
// ❗ 'state.error' is possibly 'undefined'.
  }
 
  if (state.status === "success") {
    return `Data: ${state.data}`;
  }
};

会发现在 state.error 这边出现了 TS 类型报错,TS 告诉我们,state.error 可能是未定义的,而我们不能在未定义的情况下调用 toUpperCase

原因是因为我们类型定义的有问题,这种类型定义的方式导致 errordata 是否存在是和 status 的状态无关,我们甚至可以创建一个在工程中根本不可能出现的数据:

type State = {
  status: "loading" | "success" | "error";
  error?: string;
  data?: string;
};
 
const state: State = {
  status: "loading",
  error: "This is an error", // should not happen on "loading!"
  data: "This is data", // should not happen on "loading!"
};

我们把这种类型描述为 "bag of optionals"。这是一种过于松散的类型。我们需要收紧它,使错误只能发生在错误时,而数据只能发生在成功时。

解决方案就是 Discriminated Unions

解决方案就是把我们的类型转换成 discriminated union,discriminated union 是一种存在共同属性的类型的集合,共同属性的值对于集合的每个成员都是唯一的。让我们把每个状态分成不同的对象字面量:

type State =
  | {
      status: "loading";
    }
  | {
      status: "error";
    }
  | {
      status: "success";
    };

然后,我们可以将错误和数据属性分别与错误和成功状态关联起来:

type State =
  | {
      status: "loading";
    }
  | {
      status: "error";
      error: string;
    }
  | {
      status: "success";
      data: string;
  };

现在,如果我们将鼠标悬停在 renderUI 函数中的 state.error,就会发现 TypeScript 知道 state.error 是一个字符串:

这是由于 TS 缩小了范围,它知道 state.statuserror,所以它知道 state.errorif 代码块内部的字符串。

为了清理原始类型,我们可以为每个状态使用一个类型别名:

type LoadingState = {
  status: "loading";
};
 
type ErrorState = {
  status: "error";
  error: string;
};
 
type SuccessState = {
  status: "success";
  data: string;
};
 
type State = LoadingState | ErrorState | SuccessState;

因此,如果我们平时发现自己的类型类似于 "bag of optionals",不妨考虑使用 discriminated union。

总结

  • discriminated union 是一种为类型建模的方法,这种类型可以处于几种不同的状态之一。
  • 它们有助于解决 "bag of optionals" 问题,即由于可选属性过多而导致类型过于松散。
  • discriminated union 有一个 "discriminant"–对联合中的每个成员都是唯一的字面类型。
  • TypeScript 可以根据判别式属性缩小判别联合的类型。