なぜ TypeScript の型システムが健全性を諦めているか

  • 95
    いいね
  • 0
    コメント
class Animal { kind: string }
class Dog extends Animal { dog_type: string }

function showDogType(dog: Dog) { console.log(dog.dog_type) }

// 代入不可のコンパイルエラーになってほしい
const someAnimalFunc: (animal: Animal) => void = showDogType

// 実際はここでランタイムエラー
someAnimalFunc(new Animal())

このコードがコンパイルエラーにならず, 実行時エラーになってしまうことについて.

TL;DR

  • 型システムの健全性 (型の矛盾が生じないこと) vs 利便性のトレードオフから, 現在の TypeScript は健全性を諦めている
    • 関数型はパラメータについて bivariant (共変かつ反変), 戻り値について共変としている
  • 健全性を得るための提案 (関数パラメータについて反変とする) が議論されている

関数パラメータに関しての性質: 反変性

一般に関数型には次の性質が求められる.

type F1 = (x: P1) => void
type F2 = (x: P2) => void

性質: P1P2 のサブタイプである ⇒ F2F1 のサブタイプである

この性質は 反変性 と呼ばれる. (F1, F2 に対して P1, P2 の関係が逆転していることに注意)

具体的な例では,

type DogFunc = (x: Dog) => void
type AnimalFunc = (x: Animal) => void

Animal 型の入力を期待する AnimalFuncDogFunc 型に代入しても問題なく動作するべきである. 一方で Dog 型の入力を期待する DogFuncAnimalFunc に代入できてしまうと, Dog 型以外の Animal サブタイプ (Cat 型とか) を渡される可能性があり, 矛盾が生じる.

共変な Array と共変な関数パラメータ

ところで, Dog[]Animal[] のサブタイプとみなすべきだろうか. 現在の TypeScript は Dog[]Animal[] のサブタイプと みなしている. つまり以下の性質を認めている.

性質: P1P2 のサブタイプである ⇒ P1[]P2[] のサブタイプである

この性質は 共変性 と呼ばれる.

Dog[]Animal[] のサブタイプであるか否かを判定する際, コンパイラは次のような判定を行う.

  • Dog[]Animal[] 型に代入可能か?
    • Dog[] の全てのメンバ (プロパティ, メソッド) が Animal[] 型に代入可能か?
      • Dog[].push メソッドは Animal[].push 型に代入可能か?
        • 関数型 (x: Dog) => number(x: Animal) => number に代入可能か?

ここでさっきの議論を思い出す.

関数パラメータの反変性: AnimalDog に代入可能 ⇒ (x: Dog) => number(x: Animal) => number に代入可能

AnimalDog に代入可能ではない. 関数パラメータの反変性が成り立つとすると, 「型 (x: Dog) => number(x: Animal) => number に代入可能か?」の答えは no となる. すなわち「Dog[]Animal[] 型に代入可能か?」の答えは no となってしまう.

そこで TypeScript では関数パラメータを bivariant (共変かつ反変) とすることでこれを解決している.

つまり以下の性質を許している.

AnimalDog に代入可能 もしくは DogAnimal に代入可能 ⇒ (x: Dog) => number(x: Animal) => number に代入可能

この性質は型システムの健全性を損ない冒頭コードのような矛盾を生むが, TypeScript では Array 型を共変にするためのトレードオフとしてこの矛盾を飲んでいる.

健全性: Flow の例

Flow では関数型をパラメータに関して反変, 戻り値に関して共変としている.

すなわち,

type F1 = (x: P1) => R1;
type F2 = (x: P2) => R2;

P1 が P2 のサブタイプかつ R2 が R1 のサブタイプならば, F2 は F1 のサブタイプである, としている.

その結果, 健全性は保たれる一方で, Array の共変性を諦めている. (Dog[]Animal[] 型に代入できない)

健全性: TypeScript での議論

実は 2014 年から関数パラメータを反変とするべきという提案が 議論されている.

  • エンドユーザにとって共変 / 反変の概念の理解が難しいこと
  • 健全ではないが実用的なパターンが多いこと
  • Array の共変性を捨てなければならないこと

などを理由に受け入れられていなかったが, Promise 型の不整合 (後述) などの大きな問題から, 状況が徐々に 変化している.

Promise 型の不整合

ごく単純化すると以下のような問題が生じている.

interface Promise2<T> {
    then<U>(cb: (value: T) => Promise2<U>): Promise2<U>;    
}
var animal: Promise2<Animal> = null as any
var dog: Promise2<Dog> = null as any

animal = dog // OK (then の反変性)
dog = animal // NG にしたいが, then の bivariance のためコンパイルが通る

Link