1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TSの鬼 第11回:型で縛る状態遷移モデル—ビジネスロジックを型安全に設計

Posted at

はじめに

前回

ビジネスロジックの核心には「状態遷移」がある。注文処理、ワークフロー、タスク管理など、あらゆるドメインは状態と遷移の集合で表現できる。本稿では TypeScript の型システムを用いて 不正遷移をコンパイルエラーで防止 する設計手法を解説する。


1. 状態遷移を型で表す意義

  1. 仕様ドキュメントと実装が乖離しない。
  2. ランタイムチェック不要のロジック保証。
  3. IDE 補完で遷移可能なオペレーションが自明になる。

2. ケーススタディ:EC 注文フロー

状態 遷移イベント
Placed pay / cancel
Paid ship / refund
Shipped deliver
Delivered
Cancelled
Refunded

3. Discriminated Union で表現

type OrderState =
  | { status: "Placed" }
  | { status: "Paid"; paidAt: Date }
  | { status: "Shipped"; tracking: string }
  | { status: "Delivered"; deliveredAt: Date }
  | { status: "Cancelled"; reason: string }
  | { status: "Refunded"; refundedAt: Date };
  • status が識別子(discriminator)となり、条件分岐で型が絞り込まれる。

4. 遷移関数を型安全に実装

function pay(order: Extract<OrderState, { status: "Placed" }>): Extract<OrderState, { status: "Paid" }> {
  return { status: "Paid", paidAt: new Date() };
}
  • Extract<T,U> で許可された状態のみ受け取り、遷移後の状態を返す。
  • 誤って Paid 注文に対して pay() を呼ぶと コンパイルエラー

汎用遷移ヘルパー

type Transition<S, T> = (state: S) => T;

const ship: Transition<
  Extract<OrderState, { status: "Paid" }>,
  Extract<OrderState, { status: "Shipped" }>
> = (o) => ({ status: "Shipped", tracking: "TRACK123" });

5. Finite State Machine (FSM) マップ化

type TransitionMap = {
  Placed: "Paid" | "Cancelled";
  Paid: "Shipped" | "Refunded";
  Shipped: "Delivered";
  Delivered: never;
  Cancelled: never;
  Refunded: never;
};

type Next<S extends keyof TransitionMap> = TransitionMap[S];
  • Next<"Placed">"Paid" | "Cancelled"
  • ジェネリクスで動的に「遷移先のみ許可」する関数作成が可能。

6. 網羅性チェックに never を利用

function assertNever(x: never): never {
  throw new Error("Unexpected state: " + x);
}

function handle(order: OrderState) {
  switch (order.status) {
    case "Delivered":
      console.log("完了");
      break;
    // ... 他ケース省略
    default:
      assertNever(order); // 新しい状態追加時にコンパイルエラー
  }
}

7. Zod と連携したランタイム保証

import { z } from "zod";

const PlacedSchema = z.object({ status: z.literal("Placed") });
const PaidSchema = PlacedSchema.extend({ status: z.literal("Paid"), paidAt: z.date() });
// ... 省略

type Placed = z.infer<typeof PlacedSchema>;
  • API 受信時に Zod でスキーマ検証 → 上記状態型に合流。

8. 落とし穴と最適化

落とし穴 対策
状態追加時にロジック漏れ never 網羅性チェックで強制エラー
状態フィールドが肥大化 状態ごとに intersection で共通型を分離
複数 FSM が互いに依存 コンポジションより ステートマシンライブラリ 併用を検討

まとめ

  • 状態遷移を Discriminated Union で定義し、遷移関数で 型安全なガード を掛ける。
  • Extract / ジェネリクス / never 網羅チェックで 不正遷移をコンパイル時に排除 できる。
  • Zod と組み合わせることで、外部データ流入部も含め エンドツーエンドの型安全 が完成する。

次回は ランタイムパフォーマンスと型安全性の両立 をテーマに、高速 API 通信と型保証のベストプラクティスを掘り下げる。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?