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

プログラムの複雑さ・表面積・グラフの構造

特に何かしらの出典はありません.

プログラムの複雑さに対する大局的で直感的な指標として, 表面積とグラフの構造というのを個人的に意識しているという話. いわゆる code smell をどう嗅ぎつけているか.

表面積

プログラムは最も単純には 1 つの入力チャンネル (引数) と 1 つの出力チャンネル (戻り値) でモデル化できます. 要するに関数ということですが, 関数型プログラミングに限らず大抵は似たような考え方ができます.

graph LR
  yield[ ] -- 引数 --> program[プログラム] -- 戻り値 --> return[ ]

一方で現実世界で価値のあるプログラムとなるためには引数と戻り値だけでは不十分で, 実際にはその他の入出力チャンネルも必要になってきます. 例えば,

  • 可変な変数の読み書き
  • 環境変数の読み取り
  • ユーザー入力の読み取り
  • 画面への出力
  • ファイルの読み書き
  • データベースの読み書き
  • HTTP リクエス
  • React Hooks

などなど. これらは乱暴にまとめると副作用と呼ばれる類のものです.

graph LR
  yield[ ] -- 引数 --> program[プログラム] -- 戻り値 --> return[ ]
  env[環境] -- 環境変数 --> program
  user[ユーザー] -- 入力 --> program
  program -- 出力 --> screen[画面]
  program -- 書き込み --> file[ファイル]

さて私が「表面積」と呼んでいるのはプログラムの持つ入出力チャンネルの数のことで, これが大きいほど複雑であると考えます.

なぜ表面積が多いほど複雑になるのか. 一つは大抵のプログラミング言語において, 関数 (またはそれと同等のもの) のシグネチャには入出力チャンネルのうち引数と戻り値しか明示されないことが多く, 逆に言えばそれ以外の入出力チャンネルについては暗黙的に扱われることが多いためです. プログラムの動作を把握するためにはこれらの暗黙的な入出力についても知っておく必要がありますが, そのためには関数のシグネチャのみならず, コード全体を読まなくてはなりません. これは非常に大変です.

二つ目は, 引数と戻り値以外の入出力チャンネルには, プログラム外部とのやりとりを含むものが多くあることです. これらを扱おうとした場合は全てがプログラム内部で完結している場合に比べて例外処理が多く必要になり, 特にプログラム全体に散らばってしまうと煩雑で漏れも出やすく, また重複も多くなるなど良いことがありません. またプログラムが外部と切り離せなくなってしまうと, テスタビリティの面でもセットアップが煩雑になるといった課題もあります.

三つ目は認知負荷の問題です. 表面積が大きいほどプログラムが一度に関係するものが多くなり, そして関係するものが多ければ多いほど人間が認知することが困難になります.

ではどのように表面積を減らすようにリファクタリングするかというと, 理想的には引数と戻り値以外の入出力チャンネルを, プログラムのエントリポイント (main 関数や HTTP リクエストのハンドラなど) に局所化します. こうすることでプログラムの大部分の入出力を引数と戻り値のみとすることができ, 大幅な単純化をすることができます.

graph LR
  env[環境] -- 環境変数 --> yield
  user[ユーザー] -- 入力 --> yield
  return -- 出力 --> screen[画面]
  return -- 書き込み --> file[ファイル]
  subgraph entry[エントリポイント]
    yield[ ] -- 引数 --> program[プログラム] -- 戻り値 --> return[ ]
  end

あるいはもしこのようにできなかったとしても, 入出力チャンネル自体を引数を使って注入することで, 入出力の有無を明示してプログラムの動作を把握しやすくしつつ, かつテストでのモックを使った差し替えなどを行いやすくすることもできます. ただしこの場合であっても, 必要以上にプログラムの各部分に入出力チャンネルを渡したりはせず, 局所的にも表面積を小さくすることが重要です.

グラフの構造

プログラムにおけるモジュール間の依存関係やデータフローなどに対して, それらを表現する (有向) グラフを考えることができます.

グラフの構造にはいくつか種類がありますが, 個人的に特に意識しているのは,

  • 非巡回グラフ
  • 一般のグラフ

の 3 種類です. これらは 木 < 非巡回グラフ < 一般のグラフ の順に一般性が高くなり, そしてこの順番にプログラムが複雑になる傾向があると考えます.

一般のグラフには閉路 (巡回路) があります. 閉路はモジュールの依存関係であれば相互依存している状態であり, 強く関連するもの同士が不当に複数のモジュールに分かれてしまっている可能性があります. またデータフローであれば双方向のデータフローであり, 例えば React コンポーネントであれば状態の同期というアンチパターンに陥ってしまっています.

graph TD
  A --> B
  B --> D
  B --> E
  A --> C
  B --> C
  C --> A
  C --> B

非巡回グラフは, モジュールの依存関係であれば相互依存がない状態, データフローであれば単方向のデータフローに対応します. それぞれ一般の閉路のあるグラフと比べると, より健全かつ扱いが簡単になっています.

graph TD
  A --> B
  B --> D
  B --> E
  A --> C
  B --> C

木構造はさらに枝ごとに分割統治を考えることができるため, プログラム的にも人間の認知的にもより簡単に扱うことができます.

graph TD
  A --> B
  B --> D
  B --> E
  A --> C

リファクタリングは, 過剰に一般的で複雑な構造になっているグラフを, より制約の強い簡単な構造に変形するようにして行います.

例えば途中でも例に挙げた React コンポーネントの状態管理であれば, 複数のコンポーネント間で状態を同期している状態 = 一般のグラフから,

graph LR
  ComponentA --> ComponentB
  ComponentB --> ComponentA

コンポーネントなどの一箇所で状態を管理している状態 = 木になるようにリファクタリングします.

graph TD
  Parent --> ComponentA
  Parent --> ComponentB

非巡回グラフよりも木の方が良いという話は以前記事を書きました.

まとめ

  • 表面積を減らせ
  • グラフの構造を簡単にしろ

どちらも上のような図を想像してみたり描いてみたりするのがよいです.