State パターン

オブジェクトの内部状態に応じて振る舞いを切り替え、状態遷移を明示的にモデル化するデザインパターン

設計パターンフロントエンド

State パターンとは

State パターンは、オブジェクトの振る舞いを内部状態に応じて切り替える GoF デザインパターンである (Gamma らの『オブジェクト指向における再利用のためのデザインパターン』日本語版 1995 年・改訂版 1999 年で紹介されたパターンの 1 つ)。if-else や switch 文で状態を判定する代わりに、各状態をクラスとして表現し、状態遷移を明示的にモデル化する。呼び出し側から見えるメソッドの並びは変わらず、同じメソッドを呼んでも現在の状態によって結果が変わる形になる。

Before / After

switch 文で状態を扱うと、「どの状態からどの操作が許されるか」という知識が各メソッドの分岐へ散らばる。状態を 1 つ足すたびに confirm・pay・ship・cancel をすべて開いて直す必要があり、直し漏れても型検査は通ってしまう。

// ❌ switch 文: 状態が増えるたびに全メソッドに分岐を追加
class Order {
  status: 'draft' | 'pending' | 'paid' | 'shipped' | 'cancelled' = 'draft';

  confirm() {
    switch (this.status) {
      case 'draft': this.status = 'pending'; break;
      case 'pending': throw new Error('Already confirmed');
      case 'paid': throw new Error('Already paid');
      // ... 状態が増えるたびに分岐が増える
    }
  }

  pay() {
    switch (this.status) {
      case 'pending': this.status = 'paid'; break;
      // ... 同じパターンの繰り返し
    }
  }
}

この pay() は draft の注文に対して何の分岐にも当たらず、例外も出さずに黙って戻る。State パターンでは同じ知識を状態クラス 1 つずつに閉じ込め、文脈オブジェクト (Order) は現在の状態へ委譲するだけになる。

// ✅ State パターン: 各状態がクラス、遷移が明示的
interface OrderState {
  confirm(order: Order): void;
  pay(order: Order): void;
  ship(order: Order): void;
  cancel(order: Order): void;
}

class DraftState implements OrderState {
  confirm(order: Order) { order.setState(new PendingState()); }
  pay() { throw new Error('Cannot pay a draft order'); }
  ship() { throw new Error('Cannot ship a draft order'); }
  cancel(order: Order) { order.setState(new CancelledState()); }
}

class PendingState implements OrderState {
  confirm() { throw new Error('Already confirmed'); }
  pay(order: Order) { order.setState(new PaidState()); }
  ship() { throw new Error('Cannot ship before payment'); }
  cancel(order: Order) { order.setState(new CancelledState()); }
}

class PaidState implements OrderState {
  confirm() { throw new Error('Already confirmed'); }
  pay() { throw new Error('Already paid'); }
  ship(order: Order) { order.setState(new ShippedState()); }
  // 支払い済みの取り消しは返金を伴うので、遷移ではなく別の操作として扱う
  cancel() { throw new Error('Cancelling a paid order requires a refund flow'); }
}

// 終端状態: 出荷済みとキャンセル済みはどの操作も受け付けない
class TerminalState implements OrderState {
  constructor(private readonly label: string) {}
  private reject(): never { throw new Error(`Cannot change a ${this.label} order`); }
  confirm() { this.reject(); }
  pay() { this.reject(); }
  ship() { this.reject(); }
  cancel() { this.reject(); }
}
class ShippedState extends TerminalState { constructor() { super('shipped'); } }
class CancelledState extends TerminalState { constructor() { super('cancelled'); } }

// 文脈オブジェクト側は現在の状態へ委譲するだけ。どのメソッドにも分岐が無い
class Order {
  private state: OrderState = new DraftState();
  setState(next: OrderState) { this.state = next; }
  confirm() { this.state.confirm(this); }
  pay() { this.state.pay(this); }
  ship() { this.state.ship(this); }
  cancel() { this.state.cancel(this); }
}

状態遷移図

この例の状態遷移を図にすると次のようになる。図に無い矢印は「その状態からは許されない操作」で、対応する状態クラスが例外で拒否する。

[Draft] ──confirm──→ [Pending] ──pay──→ [Paid] ──ship──→ [Shipped]
   │                     │
   │ cancel       cancel │
   └─────→ [Cancelled] ←─┘

State パターンを使うと、この状態遷移図がコードに直接反映される。各状態クラスが「この状態から何ができるか」を定義する。支払い済み以降の取り消しは返金の手続きを伴うため、この図では遷移として持たせず、別のユースケースとして切り離している。

Strategy パターンとの違い

State と Strategy は、文脈オブジェクトが差し替え可能なオブジェクトへ処理を委譲するという構造がほぼ同じで、違いは構造ではなく意図と、差し替えを誰が決めるかにある。

パターン目的切り替えのタイミング
State内部状態に応じて振る舞いを変える状態を変える操作の中で次の状態へ差し替える
Strategyアルゴリズムを外部から差し替えるクライアントが明示的に選択する

State では「支払いを受け付けた」という操作の中で PendingState が PaidState を立て、呼び出し側は次にどの状態になるかを知らない。Strategy では呼び出し側がクイックソートかマージソートかを選んで渡す。遷移が勝手に起きるわけではなく、必ずどこかのコードが次の状態を決めている点に注意したい。その責任を状態クラス側に置くか文脈オブジェクト側に置くかは設計上の選択で、状態クラス側に置くと状態同士が互いを知ることになり、文脈オブジェクト側に置くと遷移表を 1 か所で読める代わりに文脈オブジェクトが状態の種類を知る。

TypeScript の Discriminated Union による代替

TypeScript では、State パターンをクラスではなく Discriminated Union で表現する方が簡潔な場合がある。

type OrderState =
  | { readonly status: 'draft' }
  | { readonly status: 'pending'; readonly confirmedAt: Date }
  | { readonly status: 'paid'; readonly paidAt: Date }
  | { readonly status: 'shipped'; readonly shippedAt: Date }
  | { readonly status: 'cancelled'; readonly cancelledAt: Date };

function confirm(state: OrderState): OrderState {
  if (state.status !== 'draft') throw new Error(`Cannot confirm from ${state.status}`);
  return { status: 'pending', confirmedAt: new Date() };
}

// 網羅性の検査: default で never 型へ代入すると、状態を足した時点でコンパイルエラーになる
function label(state: OrderState): string {
  switch (state.status) {
    case 'draft': return '下書き';
    case 'pending': return '確定済み';
    case 'paid': return '支払い済み';
    case 'shipped': return '出荷済み';
    case 'cancelled': return 'キャンセル済み';
    default: {
      const exhaustive: never = state;
      return exhaustive;
    }
  }
}

注意点が 2 つある。1 つは、Discriminated Union それ自体が値を凍結するわけではないこと。書き換えを防ぐには上の例のように readonly を付ける必要があり、readonly は型検査の上での制約なので、実行時に値が固まるわけでもなく、入れ子になったオブジェクトの中身までは守られない。もう 1 つは、TypeScript にパターンマッチの構文は無いこと。網羅性の検査は switch の default で never 型へ代入する形で書き、状態を 1 つ足すとその代入がコンパイルエラーになることで対応漏れを見つける。

switch 文のままで良い場合

State パターンはクラスとファイルが増える。状態が 2〜3 個で遷移がほとんど変わらないなら、switch 文のままの方が全体を一目で読める。次の条件が重なるほど、状態をクラスへ切り出す価値が出る。

  • 状態の数が今後も増える見込みがある。switch 文は状態を足すたびに既存メソッドへ手を入れることになり、開放閉鎖原則の観点で不利になる。State パターンならクラスを 1 つ足す形に寄せられる
  • 状態ごとに持つデータが違う。支払い済みなら支払日時、キャンセル済みならキャンセル理由といった具合に、状態と一緒に運ぶ値が分かれている場合
  • 状態ごとの振る舞いを単体テストで分けて確かめたい。状態クラスは文脈オブジェクトを組み立てずに直接テストできる一方、switch 文では 1 つのメソッドの全分岐を通す形になる

逆に、状態ごとのデータが同じで振る舞いの差も 1 行程度なら、Discriminated Union と never による網羅性検査で足りることが多い。判断の分かれ目はクラス数の見た目ではなく、「状態を 1 つ足すときに何か所を漏れなく直す必要があるか」である。

この記事は役に立ちましたか?

関連用語

関連する記事