TypeScript Handbook を読み進めていく第九回目。
- Basic Types
- Variable Declarations
- Interfaces
- Classes
- Functions
- Generics
- Enums
- Type Inference
- Type Compatibility (今ココ)
- Advanced Types
- Symbols
- Iterators and Generators
- Modules
- Namespaces
- Namespaces and Modules
- Module Resolution
- Declaration Merging
- JSX
- Decorators
- Mixins
- Triple-Slash Directives
- Type Checking JavaScript Files
Type Compatibility
Introduction
TypeScript では、構造的部分型という考え方に基づいて型の互換性をチェックします。
構造的部分型は名目上の部分型と異なり、各型のメンバのみに基づいて互換性が判定されます。
interface Named {
name: string;
}
class Person {
name: string;
}
let p: Named;
// メンバが共通しているため、OK
p = new Person();
A Note on Soundness
TypeScript の型システムはコンパイル時に安全であると判断できない操作についても許容しています。
そのため、TypeScript がこのような不健全な振る舞いをする箇所について充分注意しておく必要があります。
この章ではそれがどのような場所で、なぜ発生するのかを説明していきます。
Starting out
TypeScript の構造的部分型の基本的なルールとして、y
が少なくとも x
と同じメンバを持つ場合に x
は y
と互換性がある、とみなします。
interface Named {
name: string;
}
let x: Named;
// y の型は { name: string; location: string; } とみなされる
let y = { name: "Alice", location: "Seattle" };
x = y;
関数呼び出し時の引数についても同じ規則が適用されます。
function greet(n: Named) {
console.log("Hello, " + n.name);
}
greet(y); // OK
y
は余分なメンバである location
を持ちますが、互換性の確認時には対象の型 (この場合 Named
) のメンバのみが考慮されるため、エラーになることはありません。
Comparing two functions
プリミティブ型やオブジェクト型の比較はそう難しくはありませんが、関数の型の比較となると若干複雑になります。
以下の例では、x
を y
に代入できるか調べるためにまず引数リストを確認します。
この時、x
の各引数の型は対応する y
の引数と互換性がないといけませんが、引数の数は一致する必要はありません。
この例では x
の各引数は y
の各引数と互換性があるため、x
を y
に代入することができます。
一方で、y
の二番目の引数に対応する引数を x
は持たないため、y
を x
に代入することはできません。
let x = (a: number) => 0;
let y = (b: number, s: string) => 0;
y = x; // OK
x = y; // エラー
y
と同じ引数でx
を呼び出しても単純に余分な引数を無視すれば良いだけだけど、逆にx
と同じ引数でy
を呼びだそうとしたら二番目の引数に渡せる値がないので NG というわけだ。
先ほどの例で y = x
とした場合のように、引数の '破棄' をなぜ許容しているのか不思議に思うかもしれませんが、これは JavaScript において引数を破棄することが一般的なためです。
例えば、Array#forEach
はコールバック関数に対して 3 つの引数を渡しますが、最初の引数だけを使用するコールバック関数でも充分役に立ちます。
let items = [1, 2, 3];
// 余分な引数を受け取りたくはない
items.forEach((item, index, array) => console.log(item));
// これでも OK!
items.forEach(item => console.log(item));
続いて戻り値の型の扱いについても見ていきましょう。
TypeScript では代入 元 の関数の戻り値の型は代入 先 の関数の戻り値の型の部分型である必要があります。
代入 先 にあるプロパティをすべて持っている必要があるということ
let x = () => ({name: "Alice"});
let y = () => ({name: "Alice", location: "Seattle"});
x = y; // OK
y = x; // エラー。xの戻り値には location プロパティがない
Function Parameter Bivariance
代入元先どちらかの関数の引数がもう一方の関数の引数に代入可能であれば、それらの関数の引数の型は等しいとみなされます。
つまり、より具体的な型を引数に取る関数が指定されているにも関わらず、その部分型を引数に指定して関数を呼び出すことが可能ということであり、この仕様を不自然に思うかもしれません。
実際にはそれが問題となるようなケースは稀であり、また、これを許容することで JavaScript でよく用いられるパターンの多くを実現できるようになります。
enum EventType { Mouse, Keyboard }
interface Event { timestamp: number; }
interface MouseEvent extends Event { x: number; y: number }
interface KeyEvent extends Event { keyCode: number }
function listenEvent(eventType: EventType, handler: (n: Event) => void) {
/* ... */
}
// MouseEvent を引数に取る関数に対し、Event を引数として呼び出すことになるため、
// 普通に考えるとまずいが、よく使われるパターンでもある
listenEvent(EventType.Mouse, (e: MouseEvent) => console.log(e.x + "," + e.y));
// 厳密に呼び出そうとするとこうなるが、こんなことはしたくない
listenEvent(EventType.Mouse, (e: Event) => console.log((<MouseEvent>e).x + "," + (<MouseEvent>e).y));
listenEvent(EventType.Mouse, <(e: Event) => void>((e: MouseEvent) => console.log(e.x + "," + e.y)));
// もちろん、まったく互換性のない型は許容されない (エラーになる)。
listenEvent(EventType.Mouse, (e: number) => console.log(e));
Optional Parameters and Rest Parameters
関数の型を比較する時に、任意の引数と必須の引数はお互いに入れ替え可能です。
代入元の関数に余分な任意の引数があってもエラーになりませんし、代入先の関数にしかない任意の引数があってもエラーになりません。
また、可変長引数は無限個の任意の引数として扱われます。
これも不自然に聞こえるかもしれませんが、大抵の関数では任意の引数が指定されないことは、その引数に undefined
が指定されていることと同義なため、問題になることはありません。
これが役に立つ例として、引数の数が (プログラマーにとっては) 予測可能だが、(コンパイラにとっては) 不明なコールバックを使用する例を見てみましょう。
function invokeLater(args: any[], callback: (...args: any[]) => void) {
/* ... 'args' を引数に取るコールバックを呼び出す ... */
}
// invokeLater は任意の数の引数でコールバックを呼び出す "かも" しれないため、脆弱である
invokeLater([1, 2], (x, y) => console.log(x + ", " + y));
// (x と y は実際には必須であるため) 紛らわしく、引数の妥当性を検証することもできない
invokeLater([1, 2], (x?, y?) => console.log(x + ", " + y));
Functions with overloads
関数がオーバーロードされている場合、代入元の関数のすべてのオーバーロードについて、代入先の関数と互換性がなければなりません。
これにより、常に代入元の関数と同じ条件で代入先の関数を呼び出すことができるようになります。
x("aaa")
とx(123)
を許容していた場合、y = x
とするためにはy
もy("aaa")
、y(123)
の両方に対応する必要があるということ
Enums
enum と数値型は互いに互換性があります。
しかし、他の enum とは互換性はありません。
enum Status { Ready, Waiting };
enum Color { Red, Blue, Green };
let status = Status.Ready;
status = Color.Green; // エラー
Classes
クラスはオブジェクトリテラルやインタフェースと同じように使用できますが、静的なメンバとインスタンスメンバを持つ点が異なります。
2 つのオブジェクトの型を比較する際には、静的なメンバは無視され、インスタンスメンバのみが比較の対象となります。
class Animal {
feet: number;
constructor(name: string, numFeet: number) { }
}
class Size {
feet: number;
constructor(numFeet: number) { }
}
let a: Animal;
let s: Size;
a = s; //OK
s = a; //OK
Private and protected members in classes
private/protected メンバを持つクラスのインスタンスを比較する場合、比較対象のクラスは同じクラスから派生した private/protected メンバを持つ必要があります。
そのため、自身の基底クラスに対して代入することは可能ですが、例え同じメンバを持っていても継承関係のないクラスには代入することはできません。
Generics
TypeScript は構造的部分型を採用しているため、型引数はメンバの型の一部として使用されない限り、型の互換性には影響を与えません。
以下の例では x も y もメンバに型引数を使用していないため、互換性があります。
interface Empty<T> {
}
let x: Empty<number>;
let y: Empty<string>;
x = y; // OK。x と y の構造は同じ
型引数が指定されている場合、ジェネリックな型は非ジェネリックな型のように扱われます。
interface NotEmpty<T> {
data: T;
}
let x: NotEmpty<number>;
let y: NotEmpty<string>;
x = y; // エラー。x と y は互換性がない
ジェネリックな型の型引数が指定されていない場合、型引数に any
が指定されているものとして比較が行われます。
let identity = function<T>(x: T): T {
// ...
}
let reverse = function<U>(y: U): U {
// ...
}
identity = reverse; // OK。(x: any)=>any は (y: any)=>any と互換性がある
Advanced Topics
Subtype vs Assignment
これまで "互換性がある" という言い方をしてきましたが、これは言語規約で規定されている用語ではありません。
TypeScript にはサブタイプ互換性と代入互換性という 2 種類の互換性が存在しており、その違いとは単に、代入互換性が any
、および enum
と (enum
に対応する) 数値型の相互代入ができるようサブタイプ互換性を拡張したもの、という点だけです。
この 2 種類の互換性は状況に応じて使い分けられています。
実際のところ、implements
句や extends
句であっても代入互換性を基に型の互換性が決定されています。
詳細については TypeScript 仕様 を参照してください。