Optional Variance Annotations の挙動メモ

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>;
};