Builder — オブジェクトリテラルと型で表す段階的な構築
リテラルで足りる場合と、型で段階的な必須を表す builder が効く場合を扱います。
このパターンが解こうとした問題
GoF は Builder を「複雑なオブジェクトの構築手順と、その表現を切り離す」パターンとして収録しています1。同じ手順で違う表現のものを組み立てられるようにする、というのが元々の狙いです。
実務でこの名前が使われるとき、多くは別の動機に寄っています。引数が多すぎるコンストラクタをどうにかしたいというものです。
new CoffeeOrder('ethiopia', 'tall', 2, 'oat', false, true, null)
これを読んで各引数の意味がわかる人はいません。GoF が主要な例に用いた C++ には名前付き引数がなく、引数の意味を呼び出し側に見せる手段が「メソッド名」しかありませんでした。だから withSize(...) のように 1 つずつ名前付きで渡す形が生まれました。
クラスで素直に書く
コーヒーの注文を題材にします。メソッドチェーンで組み立てる形が定番です。
type Size = 'short' | 'tall' | 'grande';
type Milk = 'none' | 'whole' | 'oat';
type CoffeeOrderSpec = {bean: string; size: Size; shots: number; milk: Milk};
class CoffeeOrderBuilder {
private bean = '';
private size: Size = 'short';
private shots = 1;
private milk: Milk = 'none';
withBean(value: string): this {
this.bean = value;
return this;
}
withSize(value: Size): this {
this.size = value;
return this;
}
withShots(value: number): this {
this.shots = value;
return this;
}
withMilk(value: Milk): this {
this.milk = value;
return this;
}
build(): CoffeeOrderSpec {
return {bean: this.bean, size: this.size, shots: this.shots, milk: this.milk};
}
}
// 豆を指定しないまま build() を呼べてしまい、コンパイラは何も言わない
const incomplete = new CoffeeOrderBuilder().withSize('tall').build();
読みやすさは確かに上がります。ただし最後の行が示すとおり、必須の値を入れ忘れても型では止まりません。空文字の bean を持った注文が実行時にできあがります。GoF の Builder は構築の手順と表現を切り離すことが主題で、必須項目の入れ忘れを止めることはその外にあります1。
言語機能で置き換える
TypeScript には名前付き引数の代わりになるオブジェクトリテラルがあり、省略できる項目は ? で表せます。
Much of the time, we'll find ourselves dealing with objects that might have a property set. In those cases, we can mark those properties as optional by adding a question mark (
?) to the end of their names.2
これで「引数が多すぎて読めない」という動機のほうは解消します。
type Size = 'short' | 'tall' | 'grande';
type Milk = 'none' | 'whole' | 'oat';
type CoffeeOrderSpec = {
bean: string;
size: Size;
shots: number;
milk?: Milk;
};
const order: CoffeeOrderSpec = {
bean: 'ethiopia',
size: 'tall',
shots: 2,
};
各項目に名前が付き、必須の入れ忘れはコンパイラが捕まえます。
// error TS2741: Property 'bean' is missing in type '{ size: "tall"; shots: number; }'
// but required in type 'CoffeeOrderSpec'.
const broken: CoffeeOrderSpec = {
size: 'tall',
shots: 2,
};
メソッドチェーンの版より短く、しかも入れ忘れが型で止まります。
順序に意味があるとき
リテラルで足りないのは、値を入れる順序そのものに制約があるときです。「サイズを決めるまでショット数は決められない」「豆とサイズが決まるまで確定できない」といった段階が実際にあるなら、型を段階ごとに変える書き方ができます。
type Size = 'short' | 'tall' | 'grande';
type Milk = 'none' | 'whole' | 'oat';
type CoffeeOrderSpec = {bean: string; size: Size; shots: number; milk: Milk};
// 段階ごとに「次に呼べるもの」だけを持つ型を用意する
type NeedsSize = {size: (value: Size) => NeedsShots};
type NeedsShots = {shots: (value: number) => Ready};
type Ready = {
milk: (value: Milk) => Ready;
build: () => CoffeeOrderSpec;
};
function makeReady(spec: CoffeeOrderSpec): Ready {
return {
milk: (milk) => makeReady({...spec, milk}),
build: () => spec,
};
}
export function orderCoffee(bean: string): NeedsSize {
return {
size: (size) => ({
shots: (shots) => makeReady({bean, size, shots, milk: 'none'}),
}),
};
}
const done = orderCoffee('ethiopia').size('tall').shots(2).milk('oat').build();
途中で確定しようとすると、その時点の型に build がないので止まります。
// error TS2339: Property 'build' does not exist on type 'NeedsShots'.
const tooEarly = orderCoffee('ethiopia').size('tall').build();
各段階の戻り値の型が違うので、まだ呼べない操作はそもそも候補に出てきません。エディタの補完も、その時点で呼べるものだけを表示します。クラスの版で実行時まで先送りされていた入れ忘れが、コンパイル時に移動しています。
この書き方は特別な言語機能ではなく、関数型とオブジェクト型を組み合わせただけのものです。
本連載の判断
判定: 条件付き。 動機によって答えが変わります。
引数が多くて読みにくいだけなら、オブジェクトリテラルで足ります。メソッドチェーンの builder を書く手間に見合う利点はありません。項目の省略は ?、既定値は分割代入で書けます。
段階的な builder を選ぶのは、次のどちらかに当たるときです。
- 値を入れる順序に実際の制約があり、それを型で示したい
- 途中の状態を持ち回る必要がある (組み立ての途中で別の関数へ渡すなど)
テストデータの組み立ては判断が分かれます。既定値を並べたオブジェクトを 1 つ用意して、差分だけスプレッドで上書きする書き方でたいてい足ります。段階的な builder が要るのは、テスト対象そのものが順序の制約を持っているときです。
この判定が覆る条件
段階的な builder には代償があります。段階の数だけ型が増え、途中に新しい段階を挟むと後続の型がすべて変わります。段階が 3 つを超えるあたりから、型の保守コストが入れ忘れ防止の利益を上回りやすくなります。この境目は本ガイドの経験則で、計測に基づくものではありません。
もう 1 つ、リテラルで足りるという判断は「必須の項目が型で表せる」ことが前提です。「A を指定したら B も必須」のような項目間の依存は ? では表せません。この場合は判別可能ユニオンで組み合わせを列挙するほうが、builder を作るより短く済みます。
既存記事との関係
- 命名規則 ② 関数とクラス は
~Builderを「複雑なオブジェクトの段階的構築」を表すサフィックスとして挙げ、QueryBuilderを例にしています。あちらはクラス名の付け方が主題で、パターンとしての採否は扱っていません。本章はその採否のほうを扱っています。 - テスト戦略 はテストデータビルダーを PHP で扱っています。言語も文脈も異なるため、本章とは別の観点になります。
オブジェクト型と省略可能な項目の基本は オブジェクト型と型エイリアス で扱っています。