メインコンテンツまでスキップ

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 で扱っています。言語も文脈も異なるため、本章とは別の観点になります。

オブジェクト型と省略可能な項目の基本は オブジェクト型と型エイリアス で扱っています。

Footnotes

  1. Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides『Design Patterns: Elements of Reusable Object-Oriented Software』(Addison-Wesley, 1994)。Builder は生成に関するパターンとして収録されています。意図の要約は本ガイドによるもので、原典とは照合していません。同書が C++ と Smalltalk を例に用いていることは二次情報で確認しました。Builder の意図が「constructs complex objects by separating construction and representation」であることも同じ二次情報によります。出典: Design Patterns(Wikipedia)。 2

  2. 出典: Object Types(TypeScript Handbook)の Optional Properties。