TypeScript 4.7 の optional variance annotations の挙動を若干勘違いしていたのでメモ.
例 1 (Playground):
type Extends<A, B> = A extends B ? true : false; type Bivariant<T> = {}; type Bivariant_A0 = Extends<Bivariant<"foo">, Bivariant<"foo" | "bar">>; // true type Bivariant_A1 = Extends<Bivariant<"foo" | "bar">, Bivariant<"foo">>; // true type Bivariant_A2 = Extends<Bivariant<"foo">, Bivariant<"bar">>; // true type Covariant<out T> = {}; type Covariant_A0 = Extends<Covariant<"foo">, Covariant<"foo" | "bar">>; // true type Covariant_A1 = Extends<Covariant<"foo" | "bar">, Covariant<"foo">>; // false type Covariant_A2 = Extends<Covariant<"foo">, Covariant<"bar">>; // false type Contravariant<in T> = {}; type Contravariant_A0 = Extends<Contravariant<"foo">, Contravariant<"foo" | "bar">>; // false type Contravariant_A1 = Extends<Contravariant<"foo" | "bar">, Contravariant<"foo">>; // true type Contravariant_A2 = Extends<Contravariant<"foo">, Contravariant<"bar">>; // false type Invariant<in out T> = {}; type Invariant_A0 = Extends<Invariant<"foo">, Invariant<"foo" | "bar">>; // false type Invariant_A1 = Extends<Invariant<"foo" | "bar">, Invariant<"foo">>; // false type Invariant_A2 = Extends<Invariant<"foo">, Invariant<"bar">>; // false
例 2 (Playground):
type Extends<A, B> = A extends B ? true : false; type Invariant<in out T> = {}; // A0 = false type A0 = [Invariant<"foo">, Invariant<"bar">] extends [infer Foo, infer Bar] ? Extends<Foo, Bar> : never; type Foo = Invariant<"foo">; type Bar = Invariant<"bar">; // A1 = true type A1 = Extends<Foo, Bar>;
例 3 (Playground)
type Invariant<in out T> = {}; declare const foo1: Invariant<"foo">; const bar1: Invariant<"bar"> = foo1; // ~~~~ // Type 'Invariant<"foo">' is not assignable to type 'Invariant<"bar">'. // Type '"foo"' is not assignable to type '"bar"'. type Foo = Invariant<"foo">; type Bar = Invariant<"bar">; declare const foo2: Foo; // ok const bar2: Bar = foo2;
どういうこと
TypeScript の type
で定義されるものは基本的にはエイリアスである.
つまり type F<T> = U
のような定義があったとき, 任意の型はそこに含まれる F<T>
の出現を U
に置き換えた型と同じ意味を持つということになっている.
厳密には distributive conditional types であったり tuple や array に対する mapped types があるので文字通りに置き換えただけでは意味が変わってくるものの, これらは通常の conditional types や mapped types とは異なる別の (直接記述することが許可されていない) 型のエイリアスを定義していると考えればおそらく変なことはないはず.
例 1 を見るとこの解釈が若干難しくなってくる.
例えば Invariant_A2
については先に Invariant
を展開してしまうと Extends<{}, {}>
となり明らかに true
になるはずであるが, そうはなっていない.
実際には Extends
が先に展開され, さらに conditional type の評価も Invariant
を展開しないまま行われている見える.
つまり variance annotation が存在する場合はこれまでのエイリアスとは異なり, TypeScript がどういった順番でエイリアスを展開するかが重要になってくる. 部分型の判定の前にエイリアスが展開されてしまっては variance annotation 通りの判定がされないかもしれない. (私が勘違いしていたのはここで, まさかエイリアスの展開順序で挙動が変わらないだろうと思っていた.)
ではどういった場合にエイリアスが先に展開されてしまうのかというのは, 型レベルプログラミングに慣れていると多少の勘がはたらくものの, 完全な理解はコンパイラのコードを読まなければ難しそう.
仮に外側から展開されているとしたらどうかと思って例 2 の A0
のように Extends
の前に infer
してみたが, これではまだ Invariant
は先に展開されないらしい.
一方で例 2 の A1
のように, 一度別の type
として定義してやるとそこでエイリアスが展開されるらしく, Extends<Foo, Bar>
は Extends<{}, {}>
と同じ結果になる.
これはエイリアスの右辺が variance の情報を持った特殊なオブジェクト型になっているわけではなさそうだということでもある (もしそうなら展開のタイミングは自由になる).
また例 3 のように変数の型に直接使った場合についても, エイリアスは直ちには展開されないらしい.
結論
という感じで実際にエイリアスの展開されるタイミングによって部分型の判定が変わります.
一瞬幽霊型 (例) を in out
で宣言しておけばよいかと思ったんですが, これではちょっと怖いですね.
リリースノートの通り, annotation を書くとしても実際の variance 通りにしておくのが無難そうです. そのようにしている限りは展開タイミングによって部分型判定が変わることはありません.
派手な再帰的型定義を書いたりしない場合の一般的な使い方としては, 以下のように variance が変化するのを未然に防ぐくらいでしょうか.
type List<T> = { head: () => T; // ... // XXX: 新規にメソッド追加したら covariant でなくなった append: (value: T) => List<T>; }; declare const xs: List<number>; // これまでコンパイルできていたコードがコンパイルできない const ys: List<number | string> = xs; // ~~ // Type 'List<number>' is not assignable to type 'List<string | number>'. // out があれば covariant でなくなったことに未然に気がつける type List2<out T> = { // ~~~~~ // Type 'List2<sub-T>' is not assignable to type 'List2<super-T>' as implied by variance annotation. // Types of property 'append' are incompatible. head: () => T; // ... append: (value: T) => List2<T>; }; // あるべき姿はこう type List3<out T> = { head: () => T; // ... append: <U>(value: U) => List3<T | U>; };