TypeScriptの型システム

実際に使うまで、TypeScriptの型システムについてJavaActionScriptのようなものをイメージしていたけど、いくつか他にはあまり見られない特徴のあるものだということが分かってきたので、まとめる。

Specificationはあまり読んでいなくて、コンパイラ(TypeScript 1.1.0-1)の挙動を見て書いているので、誤解はあるかもしれない。また、ここに書いたことはHandbookでだいたい言及されている。

Parameter type bivariance

TypeScriptにはジェネリクスがある。ジェネリクスを使用したときのsubtyping relationをまず見てみる。

class Base {
    foo(): void {}
}
class Derived extends Base {
    bar(): void {}
}
 
class Box<T> {
    constructor(public value: T) {}
}
 
var a: Box<Base> = new Box<Derived>(null); // ok
var b: Box<Derived> = new Box<Base>(null); // error: Cannot convert 'Box<Base>' to 'Box<Derived>'

Subtyping relation A \le Bとは型ABとしても使える、すなわち型Aの値を型Bの変数に代入することができるということだと思ってもいい。

A \le B \quad \text{iff} \quad \mathrm{Box}(A) \le \mathrm{Box}(B)

つまり、covariantになっているみたいだ。

関数型について見てみると

var c: () => Base = () => new Derived; // ok
var d: () => Derived = () => new Base; // error: Cannot convert '() => Base' to '() => Derived'
var e: (x: Base) => void = (x: Derived) => { x.bar() }; // no error, but 'e(new Base)' causes a runtime error!
var f: (x: Derived) => void = (x: Base) => {}; // ok

返り値の型に関してはcovariantになっていてこれは普通だけど、引数の型ではどちらでも通ってしまう(bivariant)。コメントに書いた通り、これは生じうるruntime errorを見逃してしまう。

このような仕様になっている理由は、JavaScriptで頻出する次のようなパターンを許したいからのようだ。

interface EventTarget {
    addEventListener(type: string, listener: (e: Event) => void): void;
    ...
}
target.addEventListener('keypress', (e: KeyboardEvent) => {
    // use e.keyCode
});

No nominal typing

Nominal typingがないとはどういうことかというと、上の例で代わりに

class Base {
    foo(): void {}
}

class Derived extends Base {}

としたときに、型BaseDerivedは同値になる。実際、こうすると上で見た型エラーも起こらない。つまり、classにしてもinterfaceにしてもstructuralな定義に別名をつけるにすぎないということになる。これを利用して

interface Id extends Number {}
var id: Id = 1;

みたいなことも書ける。

Overloading is not resolved statically

TypeScriptのOverloadingは、シグネチャごとに実装を用意しておいて、呼出側では型によって静的にdispatch先が決まるような、いわゆるOverloadingとは違う。実行時に処理を振り分けるコードを自分で書く。

function ppr(o: string): string;
function ppr(o: { ppr: () => string }): string;
function ppr(o: any): string {
	if (typeof o === 'string') {
		return '"' + o + '"';
	} else if (typeof o === 'object') {
		return o.ppr();
	}
}

ppr('a');
ppr({ ppr: () => '{}' });
ppr({}); // error: Could not select overload for 'call' expression

引数の型がanyにつぶれてしまっているように見えるけど、実際に呼出側が利用できるのは実装のない上2つのシグネチャなので大丈夫。

このようになっているのも、元々あるJavaScriptのコードに型を付与するのにはこうでないといけないというのと、またきっとTypeScriptと変換後のJavaScriptでメソッドの対応が1対1になるようにしたいからなのだろう。


TypeScriptは方針として、既存のJavaScriptライブラリとの相互運用性を重視している、さらにいえばJavaScriptでよくあるプログラミング作法にしたがって書かれたコードに対して、できるだけ型を付けて書けるようにするというところを目指しているのだと思う。逆にいえば、この世界に異なる作法を持ち込むことは想定されていない。

他、型システムと直接関係ないところでは、関数型を書くのに引数の変数名が必須なのがまだるっこしいとか、スコープの扱いがコンパイル後のJavaScriptを考えないと想像しずらいとか、あるけれどこれくらいで。