使用状态机和 TypeScript 进行领域建模


希望通过这篇文章完成的是让人们以不同的方式看待 TypeScript,并展示我认为是主流语言中最好的类型系统之一。

先上代码:

type Line = {
  sku: string;
  quantity: number;
  unitPrice: number;
};

type Order = {
  orderReference: string;
  status: string;
  lines: Line[];
};

function createOrder(orderReference: string, lines: Line[]): Order {
  return {
    orderReference: orderReference,
    lines: lines,
    status: "Open",
  };
}

function dispatchOrder(order: Order): Order {
  return {
    ...order,
    status:
"Dispatched",
  };
}

function completeOrder(order: Order): Order {
  return {
    ...order,
    status:
"Complete",
  };
}

function cancelOrder(order: Order): Order {
  return {
    ...order,
    status:
"Cancelled",
  };
}

到目前为止,它看起来像是用于处理在线订单的沼泽标准业务逻辑,尽管相当简单。
现在添加一些规则:

  • 订单状态必须为“打开”才能发货
  • 订单状态必须为打开才能取消
  • 订单状态必须为Dispatched才能完成

对此建模非常简单,我们可以将代码修改为如下所示:

type Line = {
  sku: string;
  quantity: number;
  unitPrice: number;
};

type Order = {
  orderReference: string;
  status: string;
  lines: Line[];
};

function createOrder(orderReference: string, lines: Line[]): Order {
  return {
    orderReference: orderReference,
    lines: lines,
    status: "Open",
  };
}

function dispatchOrder(order: Order): Order {
  if (order.status !==
"Open") {
    return order;
  }
  return {
    ...order,
    status:
"Dispatched",
  };
}

function completeOrder(order: Order): Order {
  if (order.status !==
"Dispatched") {
    return order;
  }
  return {
    ...order,
    status:
"Complete",
  };
}

function cancelOrder(order: Order): Order {
  if (order.status !==
"Open") {
    return order;
  }
  return {
    ...order,
    status:
"Cancelled",
  };
}

发现这段代码有一些问题,随着代码库的增长可能会出现问题:

  • Status是一个字符串类型,为拼写错误和大小写不一致留下了很大的空间。
  • 函数名称并没有描述它们在做什么。例如, dispatchOrder不仅仅是发送订单——它是:
    • 检查订单是否处于待发货的有效状态
    • 如果上述检查通过,则发货并退回订单
    • 如果上述检查失败,则按原样退回订单

对于我们状态,我们有以下可用选项:

  • Open打开
  • Dispatched发货
  • Complete完成
  • Cancelled消

如果我们只有有限数量的可用选项,显而易见的选择是创建一个联合类型来表示订单可能处于的不同状态:

type OrderState =
  | "Open"
  |
"Dispatched"
  |
"Complete"
  |
"Cancelled";

type Order = {
  orderReference: string;
  status: OrderState;
  lines: Line[];
};


通过这样做,我们正在降低未来开发人员更改状态的大小写或术语的风险,而无需考虑它们在任何地方的使用。

一个简单的改变,但我们还没有完成。

使隐式显式
一个好的软件设计原则是使隐含显化。查看我们的代码,我们应该立即知道它在做什么,而无需做出任何假设。

例如,未结订单与已完成或已取消的订单有什么区别?是什么阻止我们将 Canceled 订单传递给dispatchOrder 函数?

目前我们在每个订单上使用 status 属性,但通过正确使用我们的类型系统,我们能够使无效状态成为不可能,甚至无需运行我们的代码即可验证。

使用联合类型,我们可以将订单类型修改为表示订单在现实生活中可以采用的各种状态的联合类型:

type Line = {
  sku: string;
  quantity: number;
  unitPrice: number;
};

type OpenOrder = {
  orderReference: string;
  status: "Open";
  lines: Line[];
};

type DispatchedOrder = {
  orderReference: string;
  status:
"Dispatched";
  lines: Line[];
};

type CompletedOrder = {
  orderReference: string;
  status:
"Complete";
  lines: Line[];
};

type CancelledOrder = {
  orderReference: string;
  status:
"Cancelled";
  lines: Line[];
};

type Order =
  | OpenOrder
  | DispatchedOrder
  | CompletedOrder
  | CancelledOrder;

通过在订单可能处于的各种状态之间创建显式区分,我们能够使用我们的编译器来强加域逻辑,而不必在我们的代码中乱使用if 语句到处判断。

我们现在可以将我们的四个函数变成一个状态机:

function createOrder(orderReference: string, lines: Line[]): OpenOrder {
  return {
    orderReference: orderReference,
    lines: lines,
    status: "Open",
  };
}

function dispatchOrder(order: OpenOrder): DispatchedOrder {
  return {
    ...order,
    status:
"Dispatched",
  };
}

function completeOrder(order: DispatchedOrder): CompletedOrder {
  return {
    ...order,
    status:
"Complete",
  };
}

function cancelOrder(order: OpenOrder): CancelledOrder {
  return {
    ...order,
    status:
"Cancelled",
  };
}

通过这样做,这意味着只有正确的状态可以作为参数传递给我们的函数,因此不需要运行时属性检查,并且无法表示无效状态。

这是一个巨大的改进,但仍然存在一些问题:

  • 我们的订单状态恢复为简单的字符串
  • 我们的各种订单状态类型涉及大量代码重复——想象一下我们是否需要为每种类型添加一个新属性!

让我们减少重复:

type OrderDetail = {
  orderReference: string;
  lines: Line[];
};

type OpenOrder = OrderDetail & { status: "Open" };
type DispatchedOrder = OrderDetail & { status:
"Dispatched" };
type CompletedOrder = OrderDetail & { status:
"Complete" };
type CancelledOrder = OrderDetail & { status:
"Cancelled" };

使用 & 运算符,我们可以通过将多个其他类型连接在一起来创建一个新类型。

我们仍然依赖字符串来表示我们的状态并且每次都复制该类型模式 - 想象一下如果我们添加了一个新属性或更改了一个属性的名称。

让我们让它更有弹性地改变:

enum State {
  Open,
  Dispatched,
  Complete,
  Cancelled,
}

type OrderDetail<TStatus extends State> = {
  orderReference: string;
  lines: Line[];
  status: TStatus;
};

type OpenOrder = OrderDetail<State.Open>;
type DispatchedOrder = OrderDetail<State.Dispatched>;
type CompletedOrder = OrderDetail<State.Complete>;
type CancelledOrder = OrderDetail<State.Cancelled>;

首先,我创建了一个枚举类型来表示各种状态值。

我个人使用 Enum 而不是我们之前创建的联合类型,因为在我看来OrderDetail<"Open">不如 OrderDetail<State.Open>可读。

另一个好处是枚举对它们有一个隐含的顺序,尽管在这个例子中我们不会使用它。

最后
代码:

enum State {
  Open,
  Dispatched,
  Complete,
  Cancelled,
}

type Line = {
  sku: string;
  quantity: number;
  unitPrice: number;
};

type OrderDetail<TStatus extends State> = {
  orderReference: string;
  lines: Line[];
  status: TStatus;
};

type OpenOrder = OrderDetail<State.Open>;
type DispatchedOrder = OrderDetail<State.Dispatched>;
type CompletedOrder = OrderDetail<State.Complete>;
type CancelledOrder = OrderDetail<State.Cancelled>;

type Order =
  | OpenOrder
  | DispatchedOrder
  | CompletedOrder
  | CancelledOrder;

function createOrder(
  orderReference: string,
  lines: Line[],
): OpenOrder {
  return {
    orderReference: orderReference,
    lines: lines,
    status: State.Open,
  };
}

function dispatchOrder(order: OpenOrder): DispatchedOrder {
  return {
    ...order,
    status: State.Dispatched,
  };
}

function completeOrder(order: DispatchedOrder): CompletedOrder {
  return {
    ...order,
    status: State.Complete,
  };
}

function cancelOrder(order: OpenOrder): CancelledOrder {
  return {
    ...order,
    status: State.Cancelled,
  };
}

如您所见,我们能够将 TypeScript 令人惊叹的类型系统与状态机结合使用来执行业务规则,并使非法状态无法表示。

使用类型系统的好处是我们能够在编译时而不是运行时发现错误,这意味着提交错误代码的机会减少了(但永远不会为零)。