Builderパターンとは、オブジェクトを作る際にBuilderオブジェクトを用いるデザインパターンです。Builderオブジェクトは各パラメータを設定するメソッドを持ちます。必要なだけメソッドを呼び出してパラメータを設定し、最後にオブジェクト生成メソッドを呼び出すことで目的のオブジェクトを得ます。イメージとしてはこんな感じです。
const obj =
(new FooBarBuilder)
.foo(123)
.bar("456")
.build();
/*
{
foo: 123,
bar: "456",
}
*/
console.log(obj);
この例では、FooBarBuilder
オブジェクトがBuilderオブジェクトです。典型的にはBuilderオブジェクトのメソッドはメソッドチェーンの形で呼び出せるようになっており、foo
, bar
, build
はFooBarBuilder
のメソッドです。
このようなBuilderオブジェクトは、間違った使い方をするとうまくいきません。例えば、(new FooBarBuilder).foo(123).build()
のようにすると、barの値が指定されていないので失敗してしまうでしょう。普通にやると、そのような失敗はbuildメソッド呼び出し時の実行時エラーとして現れることになります。
しかし、型がある言語においては、このような失敗を実行時エラーではなくコンパイル時の型エラーとして検出したいという欲求が発生します。すなわち、型安全なBuilderパターンです。実際、既にRustやC#などで型安全なBuilderパターンをやってみたという記事が存在します。
ところが、これらの方法には、ビルドしたいオブジェクトに応じてBuilderオブジェクトを書きなおさなければいけないという欠点があります。そこで今回は、このような問題を克服した、型安全かつ一般的なBuilderパターンをTypeScriptで書いてみました。
ソースコード全体はこちらをご参照ください。ここではまず使用例から出します。
使用例
まず、Builderクラスを作ります。
const FooBarBuilder = builderFactory<
{
foo: number;
bar: string;
},
string
>(({ foo, bar }) => `foo = ${foo}, bar = ${bar}`);
このbuilderFactory
関数は2つの型引数と1つの引数を取ります。1つ目型引数はオブジェクトの型で、与えるべきパラメータの一覧です。2つ目はこのBuilderにより作られる値の型です。上の例は、number
型のパラメータfoo
とstring
型のパラメータbar
を指定すべきことを表しています。2つ目の型引数はstring
となっています。普通Builderオブジェクトが作るのはオブジェクトですが、ここでは簡単のために文字列にしています。
引数は{foo, bar}
という形のパラメータ一覧オブジェクトから目的の値を作る関数です。ここでは文字列を返す関数になっています。
返り値のFooBarBuilderはクラスであることに注意してください。JavaScriptに馴染みのない方には変かもしれませんが、JavaScriptはクラスを動的に生成できるのです。
ここで作ったFooBarBuilderクラスはこのように使用します。
const foobarValue = (new FooBarBuilder).foo(123).bar('456').build();
console.log(foobarValue); // foo = 123, bar = 456
const foobarValue2 = (new FooBarBuilder).bar('bar').foo(0).build()
console.log(foobarValue2); // foo = 0, bar = bar
(new FooBarBuilder).build();
// ^^^^^
// エラー: Property 'build' does not exist on type '...'
(new FooBarBuilder).foo(456).build();
// ^^^^^
// エラー: Property 'build' does not exist on type '...'
(new FooBarBuilder).foo(456).foo(123);
// ^^^
// エラー: Property 'foo' does not exist on type '...'
まず(new FoobBarBuilder)
としてBuilderオブジェクトのインスタンスを作成し、メソッドチェーンの形でパラメータを1つずつ指定します。メソッド名は先ほど型で指定したパラメータ名と同じです。
そして、下半分のエラーの例に注目してください。今回作ったBuilderオブジェクトは、パラメータを全部指定する前にbuildメソッドを呼んだり、同じパラメータを2回設定しようとしたりするのを型によって弾くことができるのです。
特に、これによりエディタによる補完がいい感じに働きます。下の画像のように、その場で使用可能なメソッドのみ補完候補に表示されます。
もうひとつ例をお見せしましょう。今度はオプショナルならパラメータがある例です。
const FooBarBazBuilder = builderFactory<
{
foo: number;
bar: string;
baz?: string;
},
string
>(({ foo, bar, baz }) => {
if (baz != null) {
return `foo = ${foo}, bar = ${bar}, baz = ${baz}`;
} else {
return `foo = ${foo}, bar = ${bar}`;
}
});
const foobarbazValue = (new FooBarBazBuilder).foo(123).bar("bar").baz("bazbaz").build();
console.log(foobarbazValue); // foo = 123, bar = bar, baz = bazbaz
const foobarbazValue2 = (new FooBarBazBuilder).bar("bar").foo(0).build();
console.log(foobarbazValue2); // foo = 0, bar = bar
// エラーの例
(new FooBarBazBuilder()).foo(456).baz("bazbaz").build();
// ^^^^^
// エラー: Property 'build' does not exist on type '...'
この例ではbaz
というパラメータがオプショナルになっています。それに対応して、foobarbazValue2
の例ではbaz
メソッドを呼ぶことなくbuild
メソッドを呼び出すことに成功しています。
ポイント
以上の例から、今回作ったBuilderオブジェクトが、型安全性という目的を達成できていることが分かりましたね。
さらに、今回の実装のポイントは、FooBarBuilder
とFooBarBazBuilder
という異なる2つのBuilderを共通の実装builderFactory
によって生成している点です。つまり、型安全のBuilderパターンを実現するロジックは個別具体のBuilderオブジェクトの形に依存しない一般的な形で書かれているのです。この点が、冒頭で挙げた他の言語とは異なる、TypeScriptならではの点と言えるでしょう。この記事ではその仕組みを解説します。
注意点
実装の解説を読めばわかりますが、メソッドチェーンを用いた書き方しかできません。つまり、以下のような使い方はできないということです。
const builder = new FooBarBuilder;
builder.foo(123);
builder.bar("bar");
builder.build();
これを実現するには変数builder
の型がメソッド呼び出しごとに変化する必要がありますが、TypeScriptでそれは厳しいです。
追記(2019/10/01):TypeScript 3.7でできるようになりそうです! 別の記事の最後で少し触れているので気になる方は読んでみてください。この記事のほうが解説が詳しいので、この記事を読み終わってから向こうを読むのがおすすめです。
また、これは実装をサボったからですが、Builderインスタンスを使い回すのもだめです。あと、プロパティ名にbuild
があると壊れます。
実装・解説
では、いよいよどのような実装になっているのかを紹介します。TypeScriptの型に関する知識が多少必要になるので、TypeScriptの型とかよく分からないという人は手前味噌ですがTypeScriptの型入門などを読んでみるとよいでしょう。
まず、今回の一番のポイントであるBuilder<Props, Result>
型の定義をお見せします。
type Builder<Props, Result> = ({} extends Props
? {
build: () => Result;
}
: {}) &
{ [P in keyof Props]-?: SetFunction<Props, P, Result> };
type SetFunction<Props, K extends keyof Props, Result> = (
value: Exclude<Props[K], undefined>
) => Builder<Pick<Props, Exclude<keyof Props, K>>, Result>;
型Props
は、定義すべきパラメータの一覧で、型Result
は最終的にbuild
により生成される値の型です。例えば、上の例でnew FooBarBuilder
というインスタンスはBuilder<{foo: number; bar: string}, string>
型を持っています。
この型は、メソッドを呼び出してオプションを設定するたびにProps
の中身が削られるようになっています。例えば、Builder<{foo: number; bar: string;}, string>
型のBuilderオブジェクトのfoo
メソッドを呼び出した返り値は、Builder<{bar: string;}, string>
型になります。ここからさらにbar
メソッドを呼び出した返り値はBuilder<{}, string>
型になります。そして、Props
が{}
になったということはもう設定すべきオプションがないということなので、build
メソッドが生えます。
このことをBuilder<Props, Result>
の定義で記述しています。まず、Builder<Props, Result>
は2つの型の交差型(intersection type)になっています。&
の左は条件型(conditional type)の構文です。これは{}
がProps
の部分型であるときは{ build: () => Result; }
になり、そうでないときは{}
になるという意味です。{}
がProps
の部分型であるという条件は、基本的にはProps
が{}
である(すなわち、もう指定するオプションがない)という意味です。ただし、実は{baz?: string}
のようにオプショナルな型のみ残っている状況でも成り立ちます。この条件はちょうどbuild
メソッドが存在すべき時の条件を表せていますね。
&
の右はオプションを設定するメソッドの定義です。これはmapped typeの構文ですね。Props
型の各キー名P
に対して、SetFunction<Props, P, Result>
という型を付けるという意味です。この定義により、まだProps
に残っているオプションに対してそれを設定するメソッドが生えます。なお、-?
はマップされる前のプロパティのオプショナル性を取り除くものです。これは、baz?: string
のようなオプショナルなプロパティに対しても対応するbaz
メソッドは常に存在することを意味しています。
SetFunction<Props, P, Result>
の詳細は省きますが、適切な型の値を受け取って、そのプロパティを削ったBuilder
オブジェクトを返すような関数になっています。例えば、SetFunction<{foo: number; bar: string}, 'foo', string>
という型は(value: number) => Builder<{bar: string}, string>
という関数型になります。
今回のメインである型の実装はこれだけです。実際のbuilderFactory
の実装は以下のようになっています。
type BuildFunction<Props, Result> = (props: Props) => Result;
const propsObject = Symbol();
const buildFunction = Symbol();
class BuilderImpl<Props, Result> {
constructor(bf: BuildFunction<Props, Result>) {
return new Proxy(
{
[propsObject]: {},
[buildFunction]: bf
},
{
get(target: any, prop: any, receiver: any) {
if (prop === "build") {
// build関数
return () => target[buildFunction](target[propsObject]);
} else {
// それ以外はsetter関数
return (value: any) => {
target[propsObject][prop] = value;
return receiver;
};
}
}
}
);
}
}
function builderFactory<Props, Result>(
bf: BuildFunction<Props, Result>
): new () => Builder<Props, Result> {
return class {
constructor() {
return new BuilderImpl(bf);
}
} as any;
}
Builderオブジェクトの実際の実装は、このようにProxyを用いたものになっています。builder.foo
のような任意のプロパティアクセスに対して、値を受け取って内部のパラメータ一覧オブジェクトに書き込む関数を返します。ただし、build
のみは例外であり、蓄積したパラメータを使用して最終的な値を生成する関数を返します。
型の面では、内部を完全に型安全に書くのは恐らく無理なので、anyを駆使して書いています。Rustでunsafeなコードを書くような感覚です。
まとめ
繰り返しになりますが、今回のソースコード全体はこちらのgistをご参照ください。
今回はTypeScriptで型安全なBuilderオブジェクトを作ってみました。きっかけはこのツイートです。これを見て「TypeScriptならいい感じにできるんじゃね?」と思ったので書いてみたものです。
特に、conditional typesをはじめとしたTypeScriptの型の表現力の高さにより、builderFactory
やBuilder<Props, Result>
という形に一般化した実装をすることができました。これができる言語はなかなか無いのではないかと思います。