State — 型で不正な遷移を潰す
状態クラス階層とユニオン型による状態表現を比較します。
このパターンが解こうとした問題
GoF は State を、内部の状態が変わったときにオブジェクトの振る舞いが変わるようにするパターンとして収録しています1。
状態を string や数値で持ち、メソッドの先頭で毎回それを分岐する書き方は、状態が増えるたびにすべてのメソッドを書き換えることになります。State は状態ごとにオブジェクトを分け、現在の状態オブジェクトへ処理を委ねることでその分岐を消します。
ここで押さえておきたいのは、State が解こうとしている問題です1。内部の状態に応じて振る舞いを変えられるようにすること、状態ごとの振る舞いを独立に定義できるようにすること、この 2 つが挙げられています。「硬貨を入れる前に商品を選ぶ」ような呼び出しを止めることは、この意図の外にあります。
クラスで素直に書く
自動販売機を題材にします。状態ごとにクラスを作り、共通の形を実装します。
type Product = 'cola' | 'tea';
const PRICES: Record<Product, number> = {cola: 150, tea: 130};
interface MachineState {
insertCoin(amount: number): MachineState;
select(product: Product): MachineState;
takeOut(): {next: MachineState; product: Product; change: number};
}
class IdleState implements MachineState {
insertCoin(amount: number): MachineState {
return new PaidState(amount);
}
select(_product: Product): MachineState {
// この状態では呼べない操作。だが型では止められない
throw new Error('先に硬貨を入れてください');
}
takeOut(): {next: MachineState; product: Product; change: number} {
throw new Error('取り出せる商品がありません');
}
}
class PaidState implements MachineState {
constructor(private readonly credit: number) {}
insertCoin(amount: number): MachineState {
return new PaidState(this.credit + amount);
}
select(product: Product): MachineState {
const price = PRICES[product];
return this.credit < price ? this : new DispensingState(product, this.credit - price);
}
takeOut(): {next: MachineState; product: Product; change: number} {
throw new Error('取り出せる商品がありません');
}
}
class DispensingState implements MachineState {
constructor(
readonly product: Product,
readonly change: number,
) {}
insertCoin(_amount: number): MachineState {
throw new Error('商品の取り出し中です');
}
select(_product: Product): MachineState {
throw new Error('商品の取り出し中です');
}
// ユニオン版の takeOut と同じく、商品と釣り銭を返す
takeOut(): {next: MachineState; product: Product; change: number} {
return {next: new IdleState(), product: this.product, change: this.change};
}
}
分岐は確かに消えました。代わりに throw new Error が 5 つ増えています。共通の形を全状態が実装する以上、その状態で意味を持たない操作にも実装が要るからです。呼び出し側から見ると、state.select(...) はどの状態でも呼べるように見えます。
言語機能で置き換える
状態を判別可能ユニオンで表し、遷移を関数にします。ここで効くのは、関数の引数の型に、その操作が許される状態だけを書けることです。
type Product = 'cola' | 'tea';
const PRICES: Record<Product, number> = {cola: 150, tea: 130};
type Idle = {phase: 'idle'};
type Paid = {phase: 'paid'; credit: number};
type Dispensing = {phase: 'dispensing'; product: Product; change: number};
type MachineState = Idle | Paid | Dispensing;
// 硬貨を入れられるのは待機中と投入済みだけ。取り出し中は受け付けない
function insertCoin(state: Idle | Paid, amount: number): Paid {
const current = state.phase === 'paid' ? state.credit : 0;
return {phase: 'paid', credit: current + amount};
}
// 商品を選べるのは投入済みのときだけ
function select(state: Paid, product: Product): Paid | Dispensing {
const price = PRICES[product];
if (state.credit < price) {
return state;
}
return {phase: 'dispensing', product, change: state.credit - price};
}
// 取り出しでは商品と釣り銭が確定している。引数の型がそれを保証する
function takeOut(state: Dispensing): {next: Idle; product: Product; change: number} {
return {next: {phase: 'idle'}, product: state.product, change: state.change};
}
const paid = insertCoin({phase: 'idle'}, 150);
const chosen = select(paid, 'cola');
const delivered = chosen.phase === 'dispensing' ? takeOut(chosen) : undefined;
throw new Error が消えました。「その状態では呼べない」を、例外ではなく引数の型で表しているからです。
// error TS2322: Type '"idle"' is not assignable to type '"paid"'.
const bad = select({phase: 'idle'}, 'cola');
実行時に例外で気付くのではなく、コンパイル時に止まります。State の意図の外にあった「不正な操作」のほうを、型が引き受けています。
状態の一覧に対して網羅的に処理したい場面では、switch と never を使います。これは Composite で扱った書き方と同じです。
本連載の判断
判定: 条件付き。ただし多くの場合は判別可能ユニオンです。
判別可能ユニオンを選ぶのは、次のいずれかに当たるときです。実務ではたいていどれかに当たります。
- 状態ごとに持てるデータが違う (待機中に
creditは存在しない) - 状態ごとに呼べる操作が違う
- 状態を保存・復元したい (素のオブジェクトなので JSON にそのまま乗る)
状態クラス階層を選ぶのは、状態の数が今後も増え続け、かつすべての状態が同じ操作の集合を持つときです。この条件では、クラスを 1 つ足すだけで済む形の利点が出ます。
判断の分かれ目は Composite と同じで、増えるのが状態の種類か、操作かです。ただし State では「状態ごとに操作の集合が違う」ことが多く、その場合は共通の形を置くこと自体が実態に合いません。だから既定がユニオン型に寄ります。
この判定が覆る条件
引数の型で操作を絞る書き方は、状態が実行時にしか決まらない場面で摩擦になります。外部から受け取った状態は MachineState として入ってくるので、select に渡す前に state.phase === 'paid' の絞り込みが要ります。この絞り込みは呼び出しのたびに書くことになり、状態遷移を扱う箇所が多いと重複します。
このとき、状態と操作をまとめて扱う入り口 (現在の状態を持ち、許される操作だけを返すもの) を 1 つ用意することになります。そこまで来ると、クラスで書いた場合との差は小さくなります。
もう 1 つ、遷移の数が多い場合は、遷移そのものを表として持つほうが見通しが良くなります。関数を並べる形は遷移が十数個を超えると全体像が見えにくくなります。この目安は本ガイドの判断で、計測に基づくものではありません。
既存記事との関係
- 条件分岐 が判別可能ユニオンと網羅チェックを扱っています。本章は同じ道具を状態遷移に当てはめた例で、基礎の説明は繰り返していません。
- Composite と判断の軸が共通です。あちらは木構造、こちらは状態遷移という違いだけで、「操作が増えるか種類が増えるか」で選ぶ点は同じです。