ことの発端
TypeScript で本格的にメタプログラミングをやってみようと思い、まずは型が同一かどうかを静的チェックするものを考えて、こんなコードを書いてみました。
type true_type = true;
type false_type = false;
type is_same<A, B> = A extends B ? B extends A ? true_type : false_type : false_type;
概ね期待する動作をするのですが、次のケースで正しく動作しませんでした。
type _1 = is_same<"a" | "b", "a" | "b">;
type _2 = "a" | "b" extends "a" | "b" ?
"a" | "b" extends "a" | "b" ?
true : false
: false;
_1
は true
型ではなく "boolean" 型(true_type | false_typeの合併型)になり、確認のためジェネリクスを展開してみて算出された _2
では true
型になります。
これは何らかのバグに違いないと鬼の首を取ったような勢いで stackoverflow で問い合わせたところ、冷静に解決方法とドキュメントのリンクを教えていただきました。
問題ばかり気にしていて、仕様書をキッチリ読んでいなかったことを反省。
読んでみると、ジェネリクス型の場合、合併型は分配して評価されるという内容でした。
「何故にジェネリクス型だけに限定して分配して評価するんじゃ。。。」という気持ちはありましたが、仕様書に書いてあるので私のボロ負けです。
というか、Exclude<>
なんかは、この分配と合併を利用しているし理屈も分かっていたのに恥ずかしい。
で、すなわち、_2
型は下記のように算出されます。
type _2 = ("a" extends "a" ? "a" extends "a" ? true : false : false) |
("a" extends "b" ? "a" extends "a" ? true : false : false) |
("b" extends "a" ? "a" extends "a" ? true : false : false) |
("b" extends "b" ? "a" extends "a" ? true : false : false) |
("a" extends "a" ? "a" extends "b" ? true : false : false) |
("a" extends "b" ? "a" extends "b" ? true : false : false) |
("b" extends "a" ? "a" extends "b" ? true : false : false) |
("b" extends "b" ? "a" extends "b" ? true : false : false) |
("a" extends "a" ? "b" extends "a" ? true : false : false) |
("a" extends "b" ? "b" extends "a" ? true : false : false) |
("b" extends "a" ? "b" extends "a" ? true : false : false) |
("b" extends "b" ? "b" extends "a" ? true : false : false) |
("a" extends "a" ? "b" extends "b" ? true : false : false) |
("a" extends "b" ? "b" extends "b" ? true : false : false) |
("b" extends "a" ? "b" extends "b" ? true : false : false) |
("b" extends "b" ? "b" extends "b" ? true : false : false);
三項演算子で連鎖しまくると比較回数がn乗に比例するのでコンパイル時間も消費しそう。。。
それはさておき、ご回答いただいた解決方法はタプルにするというものでした。
type is_same<A, B> = [A] extends [B] ? [B] extends [A] ? true_type : false_type : false_type;
ふむふむ、これで良いかと思いきや、下記のケースで誤判定します。
type ttt = is_same<"a" | any, "a" | "b">;
// ttt は true_type
そして、更に調べてみると、物凄く難解な解決方法がありました。
type is_same<A, B> = (<T>() => T extends A ? 1 : 2) extends
(<T>() => T extends B ? 1 : 2) ? true_type : false_type;
これだと期待する動作になるのですが、突如現れた T
が A
の派生かを調べて 1
型 か 2
型にするって意味がチンプンカンプンだったので、じっくり追ってみたという話です。
理解するまでの道程
以下、下記のコードを「謎コード」と定義します。
type is_same<A, B> = (<T>() => T extends A ? 1 : 2) extends
(<T>() => T extends B ? 1 : 2) ? true_type : false_type;
予想
先述のドキュメントのとおり、A extends B
で A
が合併型の場合、A
を分配して extends B
を行い、全ての結果を合併した型が得られます。
なので、これを避けるために「謎コード」では <T>() => T extends A
として、分配を避けているんだろうなぁと思うのだけど、正しいのでしょうか?
<T>() => T
まず、この中にある <T>() => T
は何なのかを考えてみます。
この部分だけを抜き出して F
という型を定義してみます。
type F = <T>() => T;
さて、2~3分考えると、TypeScriptにおいては 型から値を生成することはできない のであって、この F
という型は定義できても、値(ここでは関数)を生成することは不可能です。つまり、存在し得ない値の型ということになります。
ちなみに、下記のように型定義した場合は、型に即した値(関数)を定義するこができます。
type F2 = <T>(a: T) => T;
const f2: F2 = <T>(a: T) => a;
f2(1);
では、何のために <T>() => T
という定義をしているのか疑問ですが、このまま、もう少し確認する領域を拡げていきます。
<T>() => T extends A ? 1 : 2
次に <T>() => T extends A ? 1 : 2
ここまで拡張してみて、これが何を定義しているのか、そして、どんな型なのかを考えてみます。
まず、真っ先に思いつくのが <T>() => T
の返却値である T
が A
から派生されているかを確認して 1
か 2
という型が結果となるかと思ったのですが、それは違います。
下記のコードで検証してみました。
type X = () => number extends string ? 0 : 1;
const x1: X = 0; // <-- エラー
const x2: X = 1; // <-- エラー
const x3: X = () => 0; // <-- OK
const x4: X = () => 1; // <-- エラー
ここまでくると見えてきました。
つまり、<T>() => T extends A ? 1 : 2
にある extends
は評価されず、これはextends
を含んだジェネリクス関数型を定義している文脈ということが分かりました。
となると <T>() => T
が現実的に値(関数)が作成できない型であっても問題ないのが納得できます。
まとめると
「謎コード」は、<T>() => T extends A ? 1 : 2
というジェネリクス関数型と (<T>() => T extends B ? 1 : 2)
というジェネリクス関数型の比較をしており、これが成立するのは A
と B
が一致している場合のみということでした。
決して、<T>() => T extends A ? 1 : 2
で、この中の extends
が評価されて 1
や 2
になる訳ではない点に注意です。
所感
考えた人は天才だわ。
ただ、githubのissuesでは「謎コード」を駆逐するケースが提示されていて、読み物と楽しいけど理解するのは疲れる。。。
その後
一晩明けて、関数型に比較対象の A
と B
が入れることで、そのまま評価されるのというのであれば、下記のようにしても良いのではと考えましたが、any
が含まれるとダメでした。
type is_same<A, B> = (() => A) extends (() => B) ? true: false;
type x = is_same<"a" | "b", "b" | "a">; // <- true
type y = is_same<"a" | any, "b" | "a">; // <- true になってしまう
では、関数型の中に「評価されないextends
」を作れば良いのかということで、下記のように書いてみました。
type is_same<A, B> = (() => A extends void ? 1 : 2) extends
(() => B extends void ? 1 : 2) ? true: false;
type x = is_same<"a" | "b", "b" | "a">; // <- true
type y = is_same<"a" | any, "b" | "a">; // <- false
こうすると、「謎コード」のような T
型が現れないで済みそうな感じがするのですが、下記のケースでは extends
が評価されてしまうため、やっぱりダメでした。
type is_same<A, B> = (() => A extends void ? 1 : 2) extends
(() => B extends void ? 1 : 2) ? true: false;
type x = is_same<number, boolean>; // <- true
つまり「謎コード」では未知の型 T
が存在することで T extends
の評価がされないということか。。。スゲ~巧妙だ。。。
まぁ、型の同一性を静的に確認する手段が議論されているようなので、静観するのが良いかもですね。