バリデーション解体新書
Totality
数学の用語では全域性と呼ばれる
f = x => y関数fの定義域xの取りうる値すべてに対応する値域yが存在することである。
全域性を持たない関数の例として代表的には割り算がよく取り上げられる。
type Divide = (a:number, b: number) => numberconst divide: Divide = (a,b) => b !== 0 ? a/b : NaN割る数に0は取りえないので、このdivide関数は全域性が無いことを意味する。
全域性を持たせるためには、3つのアプローチがあるとされる。
1. 定義域にデフォルト値を返す。
2. 戻り値にエラーケースを含める。
3. 引数(定義域)を全域性担保できる範囲に限定する。
1はこれが出来る場合と出来ない場合がある。上記例のように割り算の場合、どんな数字をデフォルト値にしようとしても不適切なので、この対策は取れない。(従業員とボランティアの2つのクラスがあって、給与支払額を算出する、という計算においてはボランティアは0円を返す、みたいな手段は取れる)
2は EitherやOptionを使うことである。
type Divide = (a:number, b: number) => number | undefinedconst divide: Divide = (a,b) => b !== 0 ? a/b : undefined これで関数の定義としては全域性は保たれるが、意味的にはエラーなので
divide を呼び出した側でハンドリングが求められる。3は入力の型を0でない数値の型を作り、それを受け付けるようにする。
import z from "zod" const NonZeroNumber = z.number().refine(v => v !== 0)type NonZeroNumber = z.infer<typeof NonZeroNumber>type Divide = (a:number, b: NonZeroNumber) => numberconst divide: Divide = (a,b) => a/b divide(6, NonZeroNumber.parse(3)) // -> 2divide(6, NonZeroNumber.parse(0)) // Parse Errordivide(6, 3) // Compile Error3は2が関数を呼び出した後でエラーハンドリングが必要だったのに対し、それが前に移動しただけ、と言ってしまえばそれまでなのだが、それ以上のメリットがいくつかある。
入力が関数にとって安全な型であることが保証されるので、関数を幾つか組み合わせることもまた安全に行える。
安全な型にマッピングするのは、外部からの入力を受け取った時点(プログラムのエッジ)に寄せることができるので、Shotgun Parsingと呼ばれる過去いくつもの脆弱性を生んできたアンチパターンを避けることができる。
入力の型名で関数の仕様を明示できる。
この3番目の思考パターンをドメインモデリングに持ち込んだのが、Domain Modeling Made Functionalである。
例題
以下のようなPersonの型を考える。
type Person = { lastName: string; firstName: string; postalCd: string; prefectureCd: string; address1: string; address2: string; birthday: Date; guardians: { lastname: string; firstName: string; relationship: Relationship; } []}この
Person に対して「未成年の場合は、その保護者に子供手当を給付する」とする。これを以下のような payChildAllowance 関数として実装する。function payChildAllowance(person: Person, amount: BigDecimal) { if (calcAge(person.birthday) >= 18) { throw new Error(`成年には子供手当は支払いません`) } person.guardians.length > 0 && saveAllowance(person.guardians[0], amount);}この
payChildAllowance は成年の場合に例外が発生するので全域性が無い。このため payChildAllowance を呼んだ側ではエラーハンドリングが必要になる。し payChildAllowance のシグネチャからはその仕様が読み取れない。これを全域性を実現するには、未成年の型を作り、payChildAllowance関数は未成年のみ受け付けることができるようにする。
type UnderagePerson = { lastName: string; // 略 birthday: Date; guardians: { lastname: string; firstName: string; relationship: Relationship; } []} type OveragePerson = { lastName: string; // 略 birthday: Date;}type Person = OveragePerson | UnderagePerson function payChildAllowance(person: UnderagePerson, amount: BigDecimal) { person.guardians.length > 0 && saveAllowance(person.guardians[0], amount);}そうすると、
payChildAllowance からは例外が消え、全域関数となる。元のPersonのように属性が多いと、なんかあるごとに型を作っていくの大変じゃない? と感じることもあるだろう。なので共通の項目を共有するために、継承やIntersectionを考えるかもしれない。
type OveragePerson = { lastName: string; // 略 birthday: Date;}type UnderagePerson = OveragePerson & { guardians: { lastName: string; firstName: string; relationship: Relationship; }[]}多くのオブジェクト指向の継承で問題を起こしてきたように、これは暗にUnderagePersonがOverage Personの性質を引き継ぐことを意味するので、OveragePerson特有のプロパティを追加する時に困ったことが起きがちだ。
Domain Modeling Made Functional的には、そこで小さい型を作ってそれを合成することを奨励する。
type PersonName = { lastName: string; firstName: string;}type PostalAddress = { postalCd: string; prefectureCd: string; address1: string; address2: string;}type Birthday = { birthday: Date;}type Guardian = { name: PersonName; relationship: Relationship;}type UnderagePerson = { name: PersonName; address: PostalAddress; birthday: Birthday;}type OveragePerson = { name: PersonName; address: PostalAddress; birthday: Birthday; guardians: Guardian[]; }型を作る基準
このように全域性を基準に型を分割し、かつ大きな粒度で作るんじゃなくて、小さな型を合成するみたいなことは、型を作るおよび、どこまで抽象化/具象化するかの基準になる。
全域性は振る舞いなしには議論できないので、逆に言えばドメインとして複数のモデルが存在するように見えても、振る舞いに違いが無いのであれば、型を分けて作る必要はない。
というところで、「バリカタ」や「柔らかめ」まで型で表現するんか? と議論を呼んだこのツイートの話も、振る舞いなしには良し悪しは議論できず、「バリカタ」「柔らかめ」間の振る舞いに違いがないのであれば、この基準にしたがうとそこまで型を分ける必要はない、ということになる。
>@MinoDriven: ラーメンの構造
const 麺を茹でる = (noodle: UncookedNoodle, hardness: 麺の硬さ) => CookedNoodle