🙏

TypeScriptで振る舞いがフラグの状態に依存するデータをモデリングする際に私がした工夫

2023/02/20に公開

要約

ひとことでいうと、状態を型に押し込むことで自明なことを増やせないか?という試みです。

本文

namedeletedプロパティを持つSomeRecordというデータを考えます。
SomeRecorddeleted == falseのときのみnameを更新できるとします。

これを素直にTypeScriptのコードに落とし込むと以下のようなコードになるかと思います。

type SomeRecord = {
  name: string;
  deleted: boolean;
};

const updateName = (item: SomeRecord, name: string) => {
  if (item.deleted) {
    throw new Error('削除済みのデータは更新できません。')
  }
  return { ...item, name };
};

deleted == trueのデータを更新するようなありえない状態に対して例外を投げることで対処しています。 そしてこのようなテストコードを書くことでしょう。

test("データの名前を更新する", () => {
  expect(updateName({name: 'name0', deleted: false}, 'name')).toEqual({name: 'name', deleted: false});
});

test("削除済みのデータの名前を更新しようとすると例外を投げる", () => {
  expect(() => updateName({name: 'throw error', deleted: true}, 'name')).toThrow('削除済みのデータは更新できません。');
});

これは以下のように修正することで、そもそもdeleted == trueのデータが名前を更新する振る舞いをもたないようにできます。

type SomeRecord<Deleted extends boolean = boolean> = {
  name: string;
  deleted: Deleted;
};

const updateName = (item: SomeRecord<false>, name: string) => ({
  ...item,
  name,
});

このようにしてdeleted == trueの場合にupdateNameを呼び出そうとすると型エラーにすることができるようになりました。これで先に記したテストコードの2つ目は不要になります。

このままでも悪くはありませんが、SomeRecord<false>では何がfalseなのかパッと見てわかりません。そのため、以下のようにコードを修正して、読みやすくします。

type StateOfDelete = 'deleted' | 'undeleted';
type SomeRecord<Deleted extends StateOfDelete = StateOfDelete> = {
  name: string;
  deleted: Deleted extends 'undeleted' ? false : true;
};

const updateName = (item: SomeRecord<'undeleted'>, name: string) => ({
  ...item,
  name,
});

このように状態を型に押し込むことで

  • 仕様を満たしていないコードを書いたら実行前にわかる
  • 実装ではなく型をみれば仕様が把握できる(型はドキュメント)
  • 必要なテストケースを減らせる

ようになりました!

Discussion