Discriminated Unionを利用したStateパターンの実現

この記事について

先日公開した下記の記事について、記事冒頭で紹介した「クラスベースによる状態遷移の実装」があまりに素朴な実装であり、その後Stateパターンへの言及がほとんどなされなかった上、あたかもクラスを用いた実装そのものに問題があるようなタイトルであったことから、様々なご指摘・ご意見を頂くこととなりました。

kosui.me

この記事ではその反省を活かし、単に「Discriminated Unionを利用してStateパターンを実現する」ということにフォーカスした内容へ再構成いたしました。

はじめに

アプリケーション開発では、複数の内部状態を持つオブジェクトを取り扱うことがしばしばあります。例えば、タクシー配車アプリの配車リクエストや、CMS(Contents Management System)の記事、ECサイトの注文などが挙げられます。

このようなオブジェクトについて、内部状態に応じて振る舞いを変化させるデザインパターンとして「Stateパターン」が知られています。

この記事では、TypeScriptのクラスを使用したStateパターンを紹介したのち、クラスではなくタグ付きユニオンと関数を使用した実装例を紹介します。

単純な状態遷移

課題

タクシー配車アプリ

まずは、どの振る舞いも引数を持たない単純な状態遷移について、タクシー配車アプリの配車リクエストを例に見てみましょう。

タクシー配車アプリでは、配車リクエストが「呼び出し(Waiting)」「迎車中(EnRoute)」「乗車中(InTrip)」「完了(Completed)」といった状態を経て処理が進み、キャンセル(Cancelled)などの例外経路も存在します。

「呼び出し (Waiting)」状態の場合、「乗務員の割り当て (assignDriver)」を実行できる一方で、「迎車中 (EnRoute)」のように「走行開始 (startTrip)」は実行できません。

classDiagram
    direction LR

    class Waiting["Waiting<br>呼び出し中"] {
      state: "Waiting"
      passengerId: string
    }

    class EnRoute["EnRoute<br>迎車中"] {
      state: "EnRoute"
      passengerId: string
    }

    class InTrip["InTrip<br>乗車中"] {
      state: "InTrip"
      passengerId: string
      startTime: Date
    }

    class Completed["Completed<br>完了"] {
      state: "Completed"
      passengerId: string
      startTime: Date
      endTime: Date
    }

    class Cancelled["Cancelled<br>キャンセル済み"] {
      state: "Cancelled"
      passengerId: string
    }

    Waiting --> EnRoute : assignDriver() <br> 乗務員の割当
    EnRoute --> InTrip : startTrip() <br> 走行開始
    InTrip --> Completed : completeTrip() <br> 走行完了

    Waiting --> Cancelled : cancel()
    EnRoute --> Cancelled : cancel()
    InTrip --> Cancelled : cancel()
    Completed --> Cancelled : cancel()

状態遷移を持つオブジェクトの実装

まず、上記のような状態遷移を持つオブジェクトを1つのクラスへ実装する場合を考えます。

下記実装では、assignDriver でも startTrip でも「望む状態からの遷移か」をわざわざチェックしているため、状態ごとの振る舞いの定義がとても煩雑になります。

class TaxiRequest {
  public state: 'Waiting' | 'EnRoute' | 'InTrip' | 'Completed' | 'Cancelled';
  public passengerId: string;

  constructor(passengerId: string) {
    this.state = 'Waiting';
    this.passengerId = passengerId;
  }

  // 乗務員が配車リクエストを受けると EnRoute に状態遷移
  public assignDriver() {
    if (this.state !== 'Waiting') {
      throw new Error(`Invalid state transition: ${this.state} -> EnRoute`);
    }
    this.state = 'EnRoute';
  }

  // 乗客が乗車したら InTrip に状態遷移
  public startTrip() {
    // EnRoute のときだけ実行可能な想定だが…
    // 実はクラス外部から this.state を書き換え可能で、想定外の状態でも呼ばれるかも
    if (this.state !== 'EnRoute') {
      throw new Error(`Invalid state transition: ${this.state} -> InTrip`);
    }
    this.state = 'InTrip';
  }

Stateパターン

Stateパターンを適用することで、状態ごとの振る舞いを別のクラスとして管理できます。

先ほどの例に挙げた「配車リクエスト」を、Stateパターンに従って実装してみましょう。

refactoring.guru

Stateパターンでは、実際の状態と振る舞いをStateクラスとして表現し、変化する状態をContextクラスが保持します。

これによって、「呼び出し(Waiting)時に実行できる振る舞いは乗務員の割り当て (assignDriver)」「迎車中 (EnRoute)時に実行できる振る舞いは走行開始 (startTrip)」のように、それぞれの状態の振る舞いを各Stateクラスの実装として表現できます。

classDiagram
    direction TD
    class Context {
        State state
        handle() void
        setState(State state) void
    }
    class State {
        <<Interface>>
        handle() void
        setContext(context: Context)
    }
    class StateA {
        handle() void
    }
    class StateB {
        handle() void
    }
    Context o-- State
    State <|-- StateA
    State <|-- StateB
    note for Context "状態をコントロールするクラス"
    note for State "状態の抽象クラス"
    note for StateA "実際の状態を表現するクラス"

実装例

classDiagram
    direction LR

    class Waiting["Waiting<br>呼び出し中"] {
      state: "Waiting"
      passengerId: string
    }

    class EnRoute["EnRoute<br>迎車中"] {
      state: "EnRoute"
      passengerId: string
    }

    class Cancelled["Cancelled<br>キャンセル済み"] {
      state: "Cancelled"
      passengerId: string
    }

    Waiting --> EnRoute : handle() <br> 迎車中へ遷移
    Waiting --> Cancelled : cancel()
    Cancelled --> Cancelled : handle() <br> 何も起こらない

「呼び出し(Waiting)」を表現する WaitingState クラスでは、 handle() を呼び出すと次の状態 「迎車中 (EnRoute)」へ遷移します。一方で、「キャンセル済み(Cancelled)」を表現する CancelledState クラスでは、次の状態でもキャンセル済みのままなので、 handle() を呼び出しても何も起きません。

abstract class State {
  protected context: Context;
  constructor(context: Context) {
    this.context = context;
  }
  // 次の状態へ遷移する
  public abstract handle(): void;

  public cancel(reason: string): void {
    this.context.cancelReason = reason;
    this.context.setState(new CancelledState(this.context));
  }
}

class Context {
  private currentState: State;
  passengerId: string;
  cancelReason?: string;

  constructor(passengerId: string) {
    this.passengerId = passengerId;
    // 初期状態は Waiting とする
    this.currentState = new WaitingState(this);
  }

  // 状態オブジェクトを切り替える
  public setState(newState: State): void {
    this.currentState = newState;
  }

  public handle() {
    this.currentState.handle();
  }
}

class WaitingState extends State {
  protected context: Context;
  constructor(context: Context) {
    super(context);
    this.context = context;
  }

  // 次の状態 (迎車中) へ遷移する
  public handle(): void {
    this.context.setState(new EnRouteState(this.context));
  }
}

class CancelledState extends State {
  protected context: Context;
  constructor(context: Context) {
    super(context);
    this.context = context;
  }

  public handle(): void {
    // キャンセル済みの場合は何もしない
  }
}

//...

Discriminated Unionを利用したStateパターン

Discriminated Unionを活用することで、Stateパターンのように状態ごとの振る舞いを分けて定義できます。

まず、呼び出し(Waiting)状態について振る舞いを定義します。

assignDriver は引数に Waiting のみを取るため、この振る舞いは「呼び出し(Waiting)」状態からのみ呼び出せます。

type Waiting = Readonly<{
  kind: 'Waiting';
  passengerId: string;
}>;

const assignDriver = (state: Waiting): EnRoute => ({
  kind: 'EnRoute',
  passengerId: state.passengerId,
});

const Waiting = {
  handle: assignDriver,
} as const;

同様に他の状態についても振る舞いを定義し、これらのDiscriminated Union TaxiRequest を定義します。

type EnRoute = Readonly<{
  kind: 'EnRoute';
  passengerId: string;
}>;

const EnRoute = {
  handle: //...
} as const;

type Cancelled = Readonly<{
  kind: 'Cancelled';
  passengerId: string;
  reason: string;
}>;

const Cancelled = {
  handle: //...
} as const;

type TaxiRequest = Waiting | EnRoute | Cancelled // | ... ;

Stateパターンの「handle() を呼び出せば、状態ごとの振る舞いが実行される」という性質を表現する handle 関数を定義し、これを TaxiRequest オブジェクトに持たせておきましょう。never 型のみを引数に取る assertNever を用いることで、処理が漏れている状態があれば型検査時に気付けるようにしておきます。

const assertNever = (x: never): never => {
    throw new Error(`Unexpected value: ${x}`);
}

const handle = (state: TaxiRequest): TaxiRequest => {
  switch (state.kind) {
    case 'Waiting':
      return Waiting.handle(state);
    case 'EnRoute':
      // ...
    case 'Cancelled':
      return Cancelled.handle(state);
    default:
      return assertNever(state);
  }
};

const cancel = (state: TaxiRequest, reason: string): Cancelled => ({
  kind: 'Cancelled',
  passengerId: state.passengerId,
  reason,
});

const TaxiRequest = {
  handle,
  cancel,
};

handle は必要?

どの状態でも共通して何らかの振る舞いを実行する「 handle 」のような操作が不要な場合、単にこれを省略できます。

type Waiting = Readonly<{
  kind: 'Waiting';
  passengerId: string;
}>;

const assignDriver = (state: Waiting): EnRoute => ({
  kind: 'EnRoute',
  passengerId: state.passengerId,
});

const TaxiRequest = {
  assignDriver,
} as const;

複雑な状態遷移

状態ごとに振る舞いの入力が異なる場合

ここで、「呼び出し(Waiting)時に実行できる乗務員の割り当て (assignDriver)」や、「迎車中 (EnRoute)時に実行できる走行開始 (startTrip)」のように、状態ごとの振る舞いの入力が異なる場合を見てみましょう。

下記の例では、乗務員の割り当て (assignDriver) の場合は「乗務員ID (driverId)」を、走行開始 (startTrip) の場合は「開始日時 (startTime)」を入力として欲しています。

classDiagram
    direction LR

    class Waiting["Waiting<br>呼び出し中"] {
      state: "Waiting"
      passengerId: string
    }

    class EnRoute["EnRoute<br>迎車中"] {
      state: "EnRoute"
      passengerId: string
      driverId: string
    }

    class InTrip["InTrip<br>乗車中"] {
      state: "InTrip"
      passengerId: string
      driverId: string
      startTime: Date
    }

    class Completed["Completed<br>完了"] {
      state: "Completed"
      passengerId: string
      driverId: string
      startTime: Date
      endTime: Date
    }

    Waiting --> EnRoute : assignDriver(<b><u>driverId</u></b>) <br> 乗務員の割当
    EnRoute --> InTrip : startTrip(<b><u>startTime</u></b>) <br> 走行開始
    InTrip --> Completed : completeTrip(<b><u>endTime</u></b>) <br> 走行完了

Stateパターンの場合

振る舞いによって入力が異なる場合、assignDriverstartTripcompleteTrip のようにそれぞれの振る舞いを抽象クラス State に定義する必要があります。

この場合、例えば WaitingState の実装では startTripcompleteTrip を「例外を投げるメソッド」として実装することになります。よって、誤って「呼び出し(Waiting)」時に startTrip を呼び出す操作をした場合、実行時に気付くこととなります。

できれば誤った操作について型検査時に気が付きたいのですが、私はそれを分かりやすく実現する方法を思いつきませんでした。もしご存知の方がいれば教えて下さい。

abstract class State {
  protected context: Context;
  constructor(context: Context) {
    this.context = context;
  }
  public abstract assignDriver(driverId: string): void;
  public abstract startTrip(startTime: Date): void;
  public abstract completeTrip(endTime: Date): void;

  public cancel(reason: string): void {
    this.context.cancelReason = reason;
    this.context.setState(new CancelledState(this.context));
  }
}

class WaitingState extends State {
  protected context: Context;
  constructor(context: Context) {
    super(context);
    this.context = context;
  }

  public assignDriver(driverId: string): void {
    this.context.setState(new EnRouteState(this.context, driverId));
  }

  public startTrip(): void {
    throw new Error('乗務員が割り当てられていないため走行を開始できません。');
  }

  public completeTrip(): void {
    throw new Error('乗車が開始されていないため完了できません。');
  }
}

Discriminated Unionを利用したStateパターン

Discriminated Unionを利用する場合、それぞれの振る舞いを表現する関数を個別に定義できます。よって、「WaitingState の状態で startTrip が呼び出されている」といった不正な操作は、型検査にて発見できます。

一方で、as を利用して無理矢理に型推論の結果を無視した場合、誤った状態が関数に渡されたとしても、実行時に発見することはできないことに注意が必要です。

type Waiting = Readonly<{
  kind: 'Waiting';
  passengerId: string;
}>;

type EnRoute = Readonly<{
  kind: 'EnRoute';
  passengerId: string;
  driverId: string;
}>;

type InTrip = Readonly<{
  kind: 'InTrip';
  passengerId: string;
  driverId: string;
  startTime: Date;
}>;

const assignDriver = ({passengerId}: Waiting, driverId: string): EnRoute => ({
  kind: 'EnRoute',
  passengerId,
  driverId,
});

const startTrip = ({passengerId, driverId}: EnRoute, startTime: Date): InTrip => ({
  kind: 'EnRoute',
  passengerId,
  driverId,
  startTime,
});

const TaxiRequest = {
  assignDriver,
  startTrip,
} as const;

まとめ

この記事では、Discriminated Unionを利用したStateパターンの実現方法について、「単純な状態遷移」と「状態ごとに振る舞いの入力が異なるようなケース」の2つのケースを交えて紹介しました。