Help us understand the problem. What is going on with this article?

TypeScriptで超型安全なBuilderパターン

Builderパターンとは、オブジェクトを作る際にBuilderオブジェクトを用いるデザインパターンです。Builderオブジェクトは各パラメータを設定するメソッドを持ちます。必要なだけメソッドを呼び出してパラメータを設定し、最後にオブジェクト生成メソッドを呼び出すことで目的のオブジェクトを得ます。イメージとしてはこんな感じです。

Builderパターンの例
const obj =
    (new FooBarBuilder)
    .foo(123)
    .bar("456")
    .build();

/*
  {
    foo: 123,
    bar: "456",
  }
*/
console.log(obj);

この例では、FooBarBuilderオブジェクトがBuilderオブジェクトです。典型的にはBuilderオブジェクトのメソッドはメソッドチェーンの形で呼び出せるようになっており、foo, bar, buildFooBarBuilderのメソッドです。

このようなBuilderオブジェクトは、間違った使い方をするとうまくいきません。例えば、(new FooBarBuilder).foo(123).build()のようにすると、barの値が指定されていないので失敗してしまうでしょう。普通にやると、そのような失敗はbuildメソッド呼び出し時の実行時エラーとして現れることになります。

しかし、型がある言語においては、このような失敗を実行時エラーではなくコンパイル時の型エラーとして検出したいという欲求が発生します。すなわち、型安全なBuilderパターンです。実際、既にRustC#などで型安全なBuilderパターンをやってみたという記事が存在します。

ところが、これらの方法には、ビルドしたいオブジェクトに応じてBuilderオブジェクトを書きなおさなければいけないという欠点があります。そこで今回は、このような問題を克服した、型安全かつ一般的なBuilderパターンをTypeScriptで書いてみました。

ソースコード全体はこちらをご参照ください。ここではまず使用例から出します。

使用例

まず、Builderクラスを作ります。

Builderクラスの生成
const FooBarBuilder = builderFactory<
  {
    foo: number;
    bar: string;
  },
  string
>(({ foo, bar }) => `foo = ${foo}, bar = ${bar}`);

このbuilderFactory関数は2つの型引数と1つの引数を取ります。1つ目型引数はオブジェクトの型で、与えるべきパラメータの一覧です。2つ目はこのBuilderにより作られる値の型です。上の例は、number型のパラメータfoostring型のパラメータbarを指定すべきことを表しています。2つ目の型引数はstringとなっています。普通Builderオブジェクトが作るのはオブジェクトですが、ここでは簡単のために文字列にしています。

引数は{foo, bar}という形のパラメータ一覧オブジェクトから目的の値を作る関数です。ここでは文字列を返す関数になっています。

返り値のFooBarBuilderはクラスであることに注意してください。JavaScriptに馴染みのない方には変かもしれませんが、JavaScriptはクラスを動的に生成できるのです。

ここで作ったFooBarBuilderクラスはこのように使用します。

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回設定しようとしたりするのを型によって弾くことができるのです。

特に、これによりエディタによる補完がいい感じに働きます。下の画像のように、その場で使用可能なメソッドのみ補完候補に表示されます。

code1.png

code2.png

code3.png

もうひとつ例をお見せしましょう。今度はオプショナルならパラメータがある例です。

オプショナルなパラメータの例
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オブジェクトが、型安全性という目的を達成できていることが分かりましたね。

さらに、今回の実装のポイントは、FooBarBuilderFooBarBazBuilderという異なる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>型の定義をお見せします。

Builderの定義
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の実装は以下のようになっています。

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の型の表現力の高さにより、builderFactoryBuilder<Props, Result>という形に一般化した実装をすることができました。これができる言語はなかなか無いのではないかと思います。

uhyo
Metcha yowai software engineer
https://uhy.ooo/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした