ChatWork Creator's Note

ビジネスチャット「チャットワーク」のエンジニアとデザイナーのブログです。

TypeScriptのString Literal Typesを使った状態の管理

この記事はbuilderscon tokyo 2018のスピーカーディナーに参加した勢いで書きました。

@kyo_agoです。

最初に

Scalaの場合はsealed修飾子を使いましょう。

TypeScriptで状態を扱う

TypeScriptでon / offの値を持つ3つの状態を管理する場合、ぱっと思いつくのは以下のようなコードだと思います。

class HogeEntity {
  constructor(
    readonly enable: boolean,
    readonly selected: boolean,
    readonly focused: boolean
  ) {}
}
let hogeEntity = new HogeEntity(true, false, false);

実際に使う場合も簡単に使うには以下のようなコードになると思います。

function render({ hogeEntity }: { hogeEntity: HogeEntity }) {
  // 無効なものは除外
  if (!hogeEntity.enable) {
    return null;
  }
  // 選択されている場合
  if (hogeEntity.selected) {
    return <div className="selected" />;
  }
  // フォーカスがあたっている
  return <div className="focused" />;
}

書捨てのコードなら問題ないですが、長くメンテしていく場合以下のような問題があります。

状態が増えた場合、改修すべき箇所の把握が難しい

例えば、以下のように新しくhiddenという状態を追加するとします。

class HogeEntity {
  constructor(
    readonly enable: boolean,
    readonly hidden: boolean,
    readonly selected: boolean,
    readonly focused: boolean
  ) {}
}
let hogeEntity = new HogeEntity(true, false, false);

hogeEntity.enablehogeEntity.selectedといった状態の参照箇所が少ない場合は問題ありません。

しかし、もし、多数の箇所から参照されている場合、それぞれの参照箇所ごとに「この場合にはhogeEntity.hiddenを考慮すべき?」を判断して回る必要があります。

function getEntities() {
  // ここでは新しく追加された`hidden`は考慮する必要あるんだっけ?
  return hogeEntities.filter(hoge => hoge.enable);
}

条件の考慮漏れが担保できない

上記のHogeEntityの例では「UIの表示管理」のようなユースケースを想定していますが、例えば「料金プラン」のようなユースケースの場合、「すべてのパターンを網羅しているか?」というのが非常に重要になります。

class PaymentPlan {
  constructor(
    // 販売会社
    readonly distributor: "A" | "B" | "C",
    // 支払いプラン
    readonly plan: "100" | "200" | "300",
  ) {}
}
let paymentPlan = new PaymentPlan("A", "100");

こういったコードを想定した場合、このpaymentPlanを利用する側で以下のようなコードを書いた場合、問題が発生する可能性があります。

function render({ paymentPlan }: { paymentPlan: PaymentPlan }) {
  // A社向けの表示
  if (paymentPlan.distributor === "A") {
    return ...
  }
  // B社向けの表示
  if (paymentPlan.distributor === "B") {
    return ...
  }
  // C社向けの表示がないが、C社の場合はここが呼び出されないのか、
  // 何も返さなくていいのか、バグなのか一見わからない
}

もちろん表示確認やUnitTestで回避することも可能ではありますが、UI部分などは実装中の検証が難しい箇所もあるためコンパイラのチェックが効くほうが嬉しいことが多いと思います。

各フラグの組み合わせのルールが「コードを使う側」に記述されており、class定義を見ても把握できない

例えば上記のHogeEntityの場合、各フラグの組み合わせは3種類で8通りの組み合わせ(状態)が存在します。

ただ、もし「enablefalseの場合UI上表示しないため、それ以外のフラグは考慮しない。selectedtrueの場合、focusedは考慮しない」というルールがあった場合、考慮する必要がある状態は少なくなります。

しかし、こういったルールはHogeEntityには記述されておらず呼び出し側に記述されるため、あとからルールを把握することが難しくなります。

各状態に名前がついておらず、状態の認識がロジックでしか表現できない

例えば上記のHogeEntityで「enablefalseでもselectedtrueの場合がありうる」というルールが追加されたとします。

この「enablefalseかつselectedtrue」という状態はコード内では「enablefalseかつselectedtrueの状態」として記述されますが、もしほかのメンバーが単純にそのコードを見ても「enablefalseかつselectedtrueとは結局どういう状態なのか?」と思うかもしれません。

解決策

状態に名前をつけて集約する

こういった状況を回避するために以下のようなコードを導入します。

type HogeStatus = "disabled" | "selected" | "focused";
class HogeEntity {
    private status: HogeStatus;
    constructor(
        enable: boolean,
        selected: boolean,
        focused: boolean
    ) {
        if (!enable) {
            this.status = "disabled";
        }
        if (selected) {
            this.status = "selected";
        }
        if (focused) {
            this.status = "focused";
        }
    }

    match<R>(matcher: { [key in HogeStatus]: () => R }): R {
        return matcher[this.status]();
    }
}
let hogeEntity = new HogeEntity(true, false, false);

実際に使用する場合は以下のようになります。

function render({ hogeEntity }: { hogeEntity: HogeEntity }) {
    return hogeEntity.match({
        disabled: () => null,
        selected: () => <div className="selected" />,
        focused: () => <div className="focused" />,
    });
}

条件分岐が定義側に移動し、class定義を見れば「各フラグの優先度」、「各フラグの組み合わせルール」、「各組み合わせごとの名前」が把握できると思います。

また、呼び出し側もすべてのパターンを網羅していないとコンパイルできないため、あとから条件が追加されても考慮漏れがなくなります。

状態用のclassを定義する

さらに、上記の例ではEntityに直接methodを実装していますが、状態を管理するための型を導入することで状態の抽象化も可能になります。

type HogeStatusLiteral = "disabled" | "selected" | "focused";
class HogeStatus {
    private status: HogeStatusLiteral;
    constructor(
        enable: boolean,
        selected: boolean,
        focused: boolean
    ) {
        if (!enable) {
            this.status = "disabled";
        }
        if (selected) {
            this.status = "selected";
        }
        if (focused) {
            this.status = "focused";
        }
    }

    match<R>(matcher: { [key in HogeStatusLiteral]: () => R }): R {
        return matcher[this.status]();
    }
}
class HogeEntity {
    private status: HogeStatus;
    constructor(
        enable: boolean,
        selected: boolean,
        focused: boolean
    ) {
        this.status = new HogeStatus(enable, selected, focused);
    }

    match<R>(matcher: { [key in HogeStatusLiteral]: () => R }): R {
        return this.status.match(matcher);
    }
}
let hogeEntity = new HogeEntity(true, false, false);

状態を分離することでHogeEntityconstructor引数が増えた場合にも「HogeStatusの状態を確定するために必要な情報はなにか?」がわかりやすいという利点もあります。

type HogeStatusLiteral = "disabled" | "selected" | "focused";
class HogeStatus {
    private status: HogeStatusLiteral;
    // 状態の確定には3つのフラグがあればいい
    constructor(
        enable: boolean,
        selected: boolean,
        focused: boolean
    ) {
        if (!enable) {
            this.status = "disabled";
        }
        if (selected) {
            this.status = "selected";
        }
        if (focused) {
            this.status = "focused";
        }
    }

    match<R>(matcher: { [key in HogeStatusLiteral]: () => R }): R {
        return matcher[this.status]();
    }
}
class HogeEntity {
    private status: HogeStatus;
    constructor(
        id: HogeId,
        name: string,
        enable: boolean,
        body: string,
        selected: boolean,
        focused: boolean,
        //...様々な引数があっても状態の初期化に必要な値がわかりやすい
    ) {
        this.status = new HogeStatus(enable, selected, focused);
    }

    match<R>(matcher: { [key in HogeStatusLiteral]: () => R }): R {
        return this.status.match(matcher);
    }
}
let hogeEntity = new HogeEntity(true, false, false);

テスト

この形式の場合、HogeStatusは状態が明示されるため、テーブルドリブンテストと相性が良くなります。

import * as assert from "power-assert";

type HogeStatusLiteral = "disabled" | "selected" | "focused" | "default";
class HogeStatus {
  // テスト向けにprotectedに
  protected status: HogeStatusLiteral;
  constructor(enable: boolean, selected: boolean, focused: boolean) {
    if (!enable) {
      this.status = "disabled";
    }
    if (selected) {
      this.status = "selected";
    }
    if (focused) {
      this.status = "focused";
    }
    this.status = "default";
  }

  match<R>(matcher: { [key in HogeStatusLiteral]: () => R }): R {
    return matcher[this.status]();
  }
}
// 型安全にテストするためにテスト用classを作っていますが、
// 面倒なら一時的に型を破ってもいいと思います
class TestHogeStatus extends HogeStatus {
  getTestStatus(): HogeStatusLiteral {
    return this.status;
  }
}

describe(`HogeStatus`, () => {
  [
    {
      enable: false,
      selected: false,
      focused: false,
      result: "disabled"
    },
    {
      enable: true,
      selected: true,
      focused: false,
      result: "selected"
    },
    {
      enable: true,
      selected: false,
      focused: true,
      result: "focused"
    },
    {
      enable: true,
      selected: false,
      focused: false,
      result: "default"
    }
    // 必要に応じて例外状態も追加
  ].forEach(param => {
    it(`new ${JSON.stringify(param)}`, () => {
      let hogeStatus = new TestHogeStatus(
        param.enable,
        param.selected,
        param.focused
      );
      assert(hogeStatus.getTestStatus() === param.result);
    });
  });
});

問題点

状態が多い場合、外部から呼び出すのが面倒

こういった形式で状態を管理する場合、「状態が多い場合、外部から呼び出すのが面倒」という問題があります。

type HogeStatusLiteral = "disabled" | "selected" | "focused" | "default" | "AAA" | "BBB" | "CCC" | "DDD";
class HogeStatus {
  protected status: HogeStatusLiteral;
  constructor(enable: boolean, selected: boolean, focused: boolean .....) {
    // ...
  }
  match<R>(matcher: { [key in HogeStatusLiteral]: () => R }): R {
    return matcher[this.status]();
  }
}
let hogeEntity = new HogeEntity(true, false, false.......);
// 呼び出す側のコードが長くなる。。。
hogeEntity.match({
  disabled: () => //......
  selected: () => //......
  focused: () => //......
  default: () => //......
  AAA: () => //......
  BBB: () => //......
  CCC: () => //......
  DDD: () => //......
});

もちろんショートカット的にisDisabled(): booleanといったmethodを実装することはできます。

しかし、そういった「状態を切り出すmethod」を定義すると、上であげた「状態追加時に検証する場所を明確化する」、「新規呼び出し時に網羅性を担保する」と言ったメリットが薄くなるため避けることをおすすめします。

この場合、複数のclass、状態へ分離できないか検討するか、「このclassは複雑な状態を扱う」として諦めて複雑なまま扱いましょう。

処理の分岐をしたい場合

この形式で記述する場合、基本的には各状態毎に処理を書くことになります。

ただ、場合によってはifで条件分岐が求められることもあります。

そういった場合、各状態毎にbooleanを返すことで分岐できます。

if (
  hogeEntity.match({
    disabled: () => true,
    selected: () => false,
    focused: () => false
  })
) {
  // disabledの場合の処理
}

ただし基本的にifでの条件分岐は避けて以下の形式で記述するほうが安全に記述できます。

return hogeEntity.match({
    disabled: () => //... 
    selected: () => //... 
    focused: () => //... 
});

classの状態を表す以外の使い方

処理の結果も状態として扱う

ここまでは主にclassの状態に関して説明してきました。

しかし、この記述は処理の結果を表すこともできます。

例えば、与えられた数字をもとに表示を変える場合を考えてみます。

function render(count: number) {
    if (count <= 0) {
        return null;
    }
    if (count > 5) {
        return `5...`;
    }
    return String(count);
}

どうでしょうか?
パット見良さそうに見えますが、ここでも「条件を網羅しているか?」、「後で条件を変更する場合に変更箇所がわかるか?」、「複数箇所に処理が分散しないか?」、「テストをどうするか?」といった問題はありそうに見えます。

ここでもここまで紹介したパターンを導入することでこういった問題を回避できます。

type CountStatusLiteral = "hidden" | "over" | "under";
class CountStatus {
    private status: CountStatusLiteral;
    constructor(private count: number) {
        if (this.count <= 0) {
            this.status = "hidden";
            return;
        }
        if (this.count > 5) {
            this.status = "over";
            return;
        }
        this.status = "under";
    }
    match<R>(matcher: { [key in CountStatusLiteral]: (count: number) => R }): R {
        return matcher[this.status](this.count);
    }
}
function render(countStatus: CountStatus) {
    return countStatus.match({
        hidden: () => null,
        over: () => "...",
        under: (count: number) => String(count),
    });
}

状態に名前をつける

ここまでは主に複雑な状態を想定して紹介してきましたが、簡単な状態にも名前をつけることでわかりやすくなることはあります。

例えば、UIを実装していると以下のようなコードをよく目にすると思います。

function renderA(isStop: boolean) {
    // 変数がtrueの場合、nullを返す
    if (isStop) {
        return null;
    }
    return //...
}
function renderB(isShow: boolean) {
    // 変数がfalseの場合、nullを返す
    if (!isShow) {
        return null;
    }
    return //...
}

コード全体で「肯定形を使うか否定形を使うか」を統一できればいいですが、場合によって難しいこともあります。

この場合もbooleanのそれぞれに名前をつけることでわかりやすくなります。

type RunningStatusLiteral = "start" | "stop";
class RunningStatus {
    private status: RunningStatusLiteral;
    constructor(bool: number) {
        this.status = bool ? "start" : "stop";
    }
    match<R>(matcher: { [key in RunningStatusLiteral]: () => R }): R {
        return matcher[this.status]();
    }
}
type VisibilityLiteral = "show" | "hidden";
class VisibilityStatus {
    private status: VisibilityLiteral;
    constructor(visibility: number) {
        this.status = visibility ? "show" : "hidden";
    }
    match<R>(matcher: { [key in BoolStatusLiteral]: () => R }): R {
        return matcher[this.status]();
    }
}
// 以下のように使用する
function renderA(runningStatus: RunningStatus) {
    return runningStatus.match({
        start: () => //...
        stop: () => null,
    });
}
function renderB(visibilityStatus: VisibilityStatus) {
    return visibilityStatus.match({
        show: () => //...
        hidden: () => null,
    });
}

処理をまとめる

ここまでmatchは毎回記述していましたが、以下のような抽象化を行うことで記述の簡素化も可能になります。

export abstract class BaseStatus<TypeLiterals extends string> {
  constructor(protected value: TypeLiterals) {}
  getValue() {
    return this.value;
  }
  equals(type: TypeLiterals) {
    return this.getValue() === type;
  }
  typeEquals<T extends BaseType<TypeLiterals>>(target: T): boolean {
    return this.getValue() === target.getValue();
  }
  matchType<R>(matcher: { [key in TypeLiterals]: () => R }): R {
    return matcher[this.getValue()]();
  }
}

各状態の定義は以下のように行います。

class HogeStatus extends BaseStatus<"disabled" | "selected" | "focused"> {
}
let hogeStatus = new HogeStatus("disabled");

初期化条件も合わせて実装する場合、constructor内で実装すると複雑になりやすいので以下のようなstatic method経由で初期化するといいでしょう。

class HogeStatus extends BaseStatus<"disabled" | "selected" | "focused"> {
  static from(enable: boolean, selected: boolean, focused: boolean): HogeStatus {
    if (!enable) {
      return new HogeStatus("disabled");
    }
    if (selected) {
      return new HogeStatus("selected");
    }
    if (focused) {
      return new HogeStatus("focused");
    }
  }
}
let hogeStatus = HogeStatus.from(enable, selected, focused);

まとめ

  • buildersconはスピーカーディナーも楽しい
  • Scalaを使おう
  • 個人的にはType Match Patternみたいな名前で呼んでる
  • 状態に名前をつけることでぬけもれの防止や、条件の明示化が可能になる
  • TypeScriptのString Literal Typesを使って状態の網羅性を確保できる
  • 条件と状態のみを切り出すことでテストも容易になる