関数オーバーロードとIntersection types


Union と Intersection

flowtype の公式ドキュメントによると、Union typesは指定された型のうちどれか1つを満たす型で、Intersection typesは指定された型をすべて満たす型です。言葉による説明よりもコードを見てもらった方がわかりやすいと思います。

type A = { a: string };
type B = { b: number };

type U = A | B; // Union
type I = A & B; // Intersecion

({ a: 'hoge' }: U);       // OK!
({ b: 1 }: U);            // OK!
({ a: 'hoge', b: 1 }: U); // OK!
({ c: true }: U);         // Error!

({ a: 'hoge', b: 1 }: I); // OK!
({ a: 'hoge' }: I);       // Error!
({ b: 1 }: I);            // Error!
({ c: true }: I);         // Error!

Unionが or で、Intersectionが and みたいな感じですかね。これは素直に理解できました。ただ、この Union と Intersection を関数の型付けで考えたときに混乱してしまいました。

関数オーバーロード

number を受け取った場合は string を返し、string を受け取った場合は number を返す関数の型について考えてみます。つまり1つの関数で複数の引数パターンに対応することになるので、関数のオーバーロードということになります。どちらか片方を満たせばよいので、さきほどの Union types を使って2つの関数定義を | でつないであげれば良さそうです。ところが・・・

type A = (x: number) => string;
type B = (x: string) => number;

declare var x: A | B;

x(1);   // Error!
x('1'); // Error!

エラーが出てしまうんです。世界が崩壊しました。

8: x(1); // Error!
     ^ number. This type is incompatible with the expected param type of
4: type B = (x: string) => number;
                ^ string

入力値として1を与えていて、x の定義に number を引数として取る関数の型が含まれているんだから、推論によってAが選ばれるだろう、と考えていたのですが、どうやらそういう理屈ではないようです。A | B という型は「A か Bかわからん」という型です。「A が入ってる」とも「B が入ってる」とも言ってません。なので、呼び出し時点ではどちらの関数が格納されているのか確定できないからエラーになっている、というわけです。

・・・どうして? これだけ丁寧に説明してもらっても最初はわからなかった。ということで、さらにもらったヒントは関数にするからわかりにくいので、関数以外で考えてみて、でした。

関数以外で考えてみる

type A = number;
type B = string;

declare var x: A | B;

x.charAt(0);     // Error!
x.toPrecision(); // Error!

これはもう悩むまでもなく「当たり前じゃん!」って思っていたんですが、その理解が浅かった。charAt() のメソッド呼び出しに差し掛かったとき、提供されている型情報からはレシーバーである x が number なのか string なのか判別できないので(というかそれが Union types)、取り得る可能性(つまり number か string)のすべてにおいて charAt() が呼び出し可能かチェックされる。結果、number には charAt() が存在しないのでエラーとなっている。これ、すごく Union types のイメージに反しますよね。どれか1つ満たせばいいはずなのに、型が確定していないから全部満たしているかチェックされてしまう。

space cat
Union types で定義したのに全部満たしているかチェックされてエラーが出た時の顔

そんなわけで、自分は当初、呼び出し引数の型に着目してイメージしていた挙動と違うと感じていたわけですが、実際にエラーが発生しているのは charAt() の引数に 0 を渡しているからとか、 toPrecision() を引数なしで呼び出しているからとかの段階ではなく、どちらの型が入っているかわからないもの(Union types)に対して呼び出すには、どちらの型が入っていても問題ないように呼び出し可能かチェックする部分で引っかかっていたわけです。ややこしいね。

改めて関数オーバーロードを考える

さて、この話をした上で、改めて関数バージョンを見てみる。

type A = (x: number) => string;
type B = (x: string) => number;

declare var x: A | B;

x(1);   // Error!
x('1'); // Error!

x の型は定義からは A なのか B なのかわからない。number を引数とする関数なのか string を引数とする関数なのかわからない。わからないのでどちらの関数が入っていても問題なく呼び出し可能かを保証するために、取り得るすべての型について呼び出しを満たしているかチェックしに行く。そうすると A は問題ないが、B は string を引数に取っているので満たしていない。それでエラーが発生する。つまり、Union typesの話が出てくる前の段階でつまづいているってことです。仮に A | B という型定義であっても呼び出し時点でどっちか確定しているなら呼び出すことが可能になる。ただ、A | B という型からは確定できず、確定できないのが Union types というものなわけです。

じゃあどうするの?

関数のオーバーロードには Intersection types を使います。

type A = (x: number) => string;
type B = (x: string) => number;

declare var x: A & B;

x(1);   // Fine!
x('1'); // Fine!

Union types とは異なり、Intersection types は型が確定しています。いや、A なのか B なのか確定しているという言い方をすると「排他」のように聞こえて誤解を招くかもしれないですが、両方同時に満たしているからチェックする必要がない、ということです。なぜか? それは A & B がまさに「A と B を両方満たしている」型だから。

・・・最初はこれが受け入れがたかった。直感に反するので「そんな関数あるわけないだろ!」って思ってたんですが、大事なのはそこじゃありません。そもそも Union types を使った時のエラーはどちらか確定していないから発生していました。Intersection types は型の定義として両方満たしていると言っている。実在するかどうかではなく、両方満たしているのだから呼び出せるんだ、というロジックです。型がそう言っているのだから、それ以上詮索する必要はない、ということで Union types のときのようなチェックが不要になります。

謝辞

理解できなくて気が狂いそうになっていたところを助けていただいた皆様、本当にありがとうございました。
@bouzuya, @ktsn, @shuheikagawa, @k_kinzal, @wreulicke (リプライもらった順、敬称略)