🙏
TypeScriptで振る舞いがフラグの状態に依存するデータをモデリングする際に私がした工夫
要約
ひとことでいうと、状態を型に押し込むことで自明なことを増やせないか?という試みです。
本文
name
とdeleted
プロパティを持つSomeRecord
というデータを考えます。
SomeRecord
はdeleted == 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