在 TypeScript 中使用可区分的联合类型

编译时间与运行时类型的困境

TypeScript 是一种编译为 JavaScript 的类型化语言,它本身没有超出原语的类型。因此,当开发人员编写 TypeScript 时,它会被转译为 JavaScript,然后在运行时(例如浏览器或服务器端的 Nodejs)执行。转译过程利用静态类型信息来捕获整个类别的潜在错误,但生成的运行时代码仍然只是无类型的 JavaScript。

这有一些含义,让从运行时类型语言转向 TypeScript 的开发人员感到惊讶。其一,您无法在运行时检查除基元之外的任何内容的类型。在实践中:

// primitives work...
const myNumber = 1;
const myString = 'some string';

typeof myNumber; // -> "number"
typeof myString; // -> "string"

// ...but more sophisticated types don't
type MyType = {
  name: string;
  func: () => number;
}

const myValue: MyType = {
  name: 'some name for my type',
  func: () => 123
}

typeof myValue; // -> "object", nothing more specific than that
复制代码

问题

所以假设我们有两种类型,Catand Dog,以及一种Animal是两者的联合的类型:

// all animals in our case share some base attributes
type BaseAnimal = {
  name: string;
  isFluffy: boolean;
}

// cats meow
type Cat = BaseAnimal & {
  meow: () => string;
}

// dogs bark
type Dog = BaseAnimal & {
  bark: () => string;
}

type Animal = Cat | Dog;

const whiskers: Cat = {
  name: 'Whiskers',
  isFluffy: true,
  meow: () => 'MEOWWW!' // Whiskers meows loudly
}

const pupper: Dog = {
  name: 'Pupper',
  isFluffy: false,
  bark: () => 'awooo' // Pupper has a gentle awoo instead of a loud bark
}

console.log(whiskers.meow()) // -> "MEOWWW!"
console.log(pupper.bark()) // -> "awooo"
复制代码

现在让我们为 an 编写一些业务逻辑Animal来发出声音,无论它是 aCat还是 a Dog

const makeNoise = (animal: Animal): string => {
  // ???
}
复制代码

如屏幕截图所示,TypeScript 在这里无法真正帮助您。

TypeScript 无法处理 meow 和 bark 方法

由于类型的编译时与运行时行为,你不能做这样的事情:

const makeNoise = (animal: Animal): string => {
  // Doesn't work because the type doesn't exist at runtime,
  // typeof will just return 'object'
  if (typeof animal === 'Dog') {
    return animal.bark();
  }

  // ...
}
复制代码

相反,您可以这样做,但 TypeScript 对此并不满意:

const makeNoise = (animal: Animal): string => {
  if (typeof animal.bark === 'function') {
    return animal.bark()
  }

  if (typeof animal.meow === 'function') {
    return animal.meow()
  }

  return 'not implemented!'
}

console.log(makeNoise(whiskers)) // -> "MEOWWW!"
console.log(makeNoise(pupper)) // -> "awooo"
复制代码

TypeScript 的不幸来自于这样一个事实.meow().bark()Animal. 错误如下所示:

TypeScript 无法处理部分准确的信息

即使您接受了这种方法,如果复杂性增加会怎样?假设我们想为类型添加一个Cat.purr()and方法,并将它们也添加到输出中。代码会变得更难立即使用,语句更复杂,错误潜入的空间更大。Dog.growl()``makeNoise``if

解决方案

让运行时“知道”我们的编译时类型的解决方案非常简单。我们所需要的只是类型的标签,以便能够唯一地识别每个变体,并根据该信息进行区分。 实际上,这意味着添加一个具有文字值的属性,该属性可用于在编译时和运行时识别每个变体。

注意命名。 只要名称在变体之间保持一致,就可以将属性称为任何名称。这里我们使用_t. 其他常用名称是typetagkind,带或不带下划线。它也可能与正在建模的领域更相关,因此在我们的动物案例中,我们可以使用类似species.

// cats meow
type Cat = BaseAnimal & {
  _t: 'cat', // <- the discriminator for cat
  meow: () => string;
}

// dogs bark
type Dog = BaseAnimal & {
  _t: 'dog', // <- the discriminator for dog
  bark: () => string;
}

type Animal = Cat | Dog;
复制代码

请注意,TypeScript 的值_t必须是唯一的,才能唯一标识每个变体。现在业务逻辑变得更简单了:我们需要做的就是检查 的值_t,TypeScript 编译器知道根据它自动缩小类型。例如,and Animalwith_t === 'dog'可以从缩小AnimalDog

还要注意assertNever助手。这里它用于确保所有不同的变体都在switch语句的分支中处理。

const assertNever = (n: never): never => {
  throw new Error('Should never happen')
}

const makeNoise = (animal: Animal): string => {
  switch (animal._t) {
    case 'cat':
      return animal.meow();
    case 'dog':
      return animal.bark();
    default:
      return assertNever(animal);
  }
}
复制代码

以下屏幕截图说明了编译器如何能够根据我们所在的代码分支从通用Animal到特定缩小范围。Cat``Dog

编译器可以根据代码分支缩小类型

实际示例——异步 HTTP 状态

虽然前面的示例很有希望是说明性的,但我们很少编写有关猫和狗的代码。一个更有用和完整的示例可能是 SPA 应用程序中获取数据的熟悉情况:呈现不同的状态。

我们想象中的应用程序以干净状态开始,然后执行异步 HTTP API 调用以获取一些数据,然后根据请求发生的情况呈现结果成功或错误。这是一个最小的例子:

type Data = {
  items: string[];
};

type State =
  | { _t: "initial" }
  | { _t: "loading" }
  | { _t: "error"; err: Error }
  | { _t: "success"; data: Data };
复制代码

如您所见,我们可以使用可区分的联合来枚举不同的已知状态。这样,当我们编写渲染逻辑时,我们不需要做任何“if data then do something with data”类型的检查。相反,我们可以只检查 的值_t,编译器知道根据它来缩小它的范围。

再次注意,_t只要变体之间的名称一致,它也可以称为任何其他名称。在这种情况下,另一个合理的鉴别器名称示例可能是status.

该示例是在反应中,但也可以在任何其他渲染模式中完成。

const renderer = (state: State) => {
    switch (state._t) {
      case "initial":
        return <p>Click button to start</p>;
      case "loading":
        return <p>Loading...</p>;
      case "error":
        return (
          <div>
            <h3>Oops, error happened!</h3>
            <p>{state.err.message}</p>
          </div>
        );
      case "success":
        return (
          <div>
            <h3>Here's your data:</h3>
            <ol>
              {state.data.items.map((i) => (
                <li key={i}>{i}</li>
              ))}
            </ol>
          </div>
        );
    }
  }
复制代码

其他用例

我发现有区别的联合在操作结果需要在高层次上分类为成功或失败的情况下特别有用(提示:Either monad是一个非常有用的模式),以及成功和/的不同变体或分类更深的失败。所以想想这样的事情:

type Failure = LogicFailure | DatabaseFailure;

type Success = SuccessWithData | SuccessWithoutData;

type Result = Failure | Success;
复制代码

猜你喜欢

转载自juejin.im/post/7222288378460733498
今日推荐