存在型とそのエンコーディング

「型は未知だが何らかの具体型が存在する」ことを表す型。全称量化 (パラメトリック多相, ∀T) が「呼び出し側が型を選ぶ」のに対し、存在量化 (∃T) は「中身が型を知っているが外からは隠蔽されている」関係を表す。Tagged Union のパターンマッチを汎用関数化する際に必要になり、TypeScript には存在型が無いため回避テクニックを使う、という文脈で現れる (ts-discriminated-union-match)。

なぜ必要になるか

判別共用体 U = A | B | ... の各タグに対応する処理を持つ matchers を、match(item, key, matchers) という汎用関数で呼びたい。item: T extends U は共用体 全体 を表す型だが、matchers[item[key]] の引数は共用体の ある具体型 を要求する。両者が一致せず型エラーになる。

本来これは「item の実体は U のどれか1つの型 T である (その T が何かは静的には不明)」という 存在型 で表すべき状況。∃T. (item: T, matcher: T => void) のように、同じ未知の T で値と処理を束ねられれば安全に呼び出せる。

カプセル化によるエンコーディング

TypeScript の型システムに存在型は無いので、クロージャ (カプセル化) で隠蔽 して回避する (uhyo「カプセル化で我慢しよう」)。T を関数シグネチャの外に直接露出させず、getMatcher の内側に閉じ込める。

const getMatcher = <T extends U, U extends Record<K, string>, K extends Key>(
    item: T, key: K, matchers: Matchers<U, K>
): (item: T) => void => (item) => matchers[item[key]];
 
const match = <T extends U, U extends Record<K, string>, K extends Key>(
    item: T, key: K, matchers: Matchers<U, K>
) => getMatcher(item, key, matchers)(item);

T が結果型に現れない形にすることで、コンパイラに具体化を強制せず型エラーを解消できる。これは存在型を「ある型を内部に閉じ込めた抽象データ」として実現する古典的手法 (存在型 ≒ 情報隠蔽・モジュール) の TypeScript 版にあたる。

一般論

  • 存在型は OCaml の first-class modules、Haskell の forall を伴う data Showable = forall a. Show a => MkShowable a、Rust の dyn Trait/impl Trait などで提供される。これらはいずれも「中身の型を隠して、それに対する操作だけを公開する」点で共通。
  • 全称 ↔ 存在の双対性: (∃x. P(x)) → Q∀x. (P(x) → Q) と同型 (継続渡し/CPS によるエンコード)。上記のカプセル化も、この同型を使って存在型を全称型 (普通のジェネリック関数) に変換していると見なせる。

関連