型の互換性

イントロダクション

TypeScriptでの型の互換性は、構造的部分型(structural subtyping)がもとになります。 構造的部分型は、型の結びつけを単にそれらのメンバーをもとにして行う方法です。 これは「名目上の型付け(nominal typing)」とは対照的です。 次のコードを例に考えてみましょう。

interface Named {
    name: string;
}

class Person {
    name: string;
}

let p: Named;
p = new Person(); // 構造的に型が同じであるため、問題ありません

C#やJavaのような「名目上の型付け」を行う言語では、 Personクラスは、自身がNamedインターフェースを実装していると名言していないため、 この等価コードはエラーとなります。

TypeScriptの構造的型システムは、JavaScriptのコードの典型的な書き方をもとに設計されました。 JavaScriptは関数式やオブジェクトリテラルのように、広い範囲で無名オブジェクトが使用されており、 JavaScriptのライブラリで見られるように「名目上の型付け」の代わりに「構造的型システム」を使用した表現の方が、 より自然であると言えます。

信頼できない動作に対する注意点

TypeScriptの型システムは、コンパイル時には安全なのか知ることができない特定の動作を許可してしまいます。 型システムがこの特性を持つ場合、これは"警告"の対象にはならないと言われています。 TypeScriptが信頼できない動作を許可してしまう箇所は、開発者が慎重に検討する必要があります。 このドキュメントを通して、これらが発生する状況とその背後にある要因を説明していきます。

基本ルール

TypeScriptの構造的型システムのための基本ルールに、 「yが少なくともxと同じメンバーを持つのであれば、 xyと互換性がある」というものがあります。 例えば、

interface Named {
    name: string;
}

let x: Named;
// yの型は{ name: string; location: string; }であると推論されます。
let y = { name: "Alice", location: "Seattle" };
x = y;

yxへ割り当て可能なのかを確認するために、 コンパイラはyの中にxの各プロパティに対応するプロパティが存在するのかをチェックします。 このケースでは、ynameという文字列のメンバを持っていなければいけません。 ここではそのプロパティを持つため、割り当てることが可能でした。

割り当てに対する同じルールが、関数呼び出しの引数のチェックに使用されています。

function greet(n: Named) {
    alert("Hello, " + n.name);
}
greet(y); // OK

yは他にlocationプロパティを持ちますが、これによってエラーが発生しないことに注目してください。 対象となるメンバーの型のみ(ここではNamed)が、互換性チェック時に考慮されます。

この比較のプロセスは、各メンバーとサブメンバーの型も含めて再帰的に行われます。

2つの関数の比較

プリミティブ型とオブジェクト型の比較が簡単である一方、 関数の互換性の比較はどのように行われるべきなのか、という問いに応えるのは少し難しいと言えます。 並べられる引数だけが異なる2つの基本的な関数の例をもとに始めてみましょう。

let x = (a: number) => 0;
let y = (b: number, s: string) => 0;

y = x; // OK
x = y; // Error

xyに割り当てられるのかをチェックするためには、まず引数の一覧を確認します。 xの各引数は、yの対応する各引数が互換性のある型でなければいけません。 引数の型の名前は考慮されず、それらの型のみが考慮されることに注意してください。 このケースでは、xの各引数はyの対応する各引数に互換性があるため、 割り当てが可能となっています。

2つ目の割り当てはエラーとなっています。 yは2つ目の引数を必要としていますが、xはこれを持たないため、割り当てが不可となっています。

あなたはx = yの例のように、何故引数を'破棄'してしまうことが許されるのかを疑問に感じたかもしれません。 この割り当てが許される理由は、余った関数の引数が無視されることが実際のJavaScriptで非常によく見られるためです。 例えばArray#forEachはコールバック関数に、その配列要素、インデックス、それを取り込んでいる配列の3つの引数を提供します。 それでもやはり、最初の引数だけを使用するコールバックを提供するのが非常に便利です。

let items = [1, 2, 3];

// Don't force these extra parameters
items.forEach((item, index, array) => console.log(item));

// Should be OK!
items.forEach(item => console.log(item));

次に戻り値の型がどのように扱われるのかを、戻り値の型だけが異なる2つの関数を使って確認してみましょう。

let x = () => ({name: "Alice"});
let y = () => ({name: "Alice", location: "Seattle"});

x = y; // OK
y = x; // x()はlocationプロパティが無いためエラー

型システムは、元(左式)となる関数の戻り値の型が、対象(右式)の戻り値の型の部分型(subtype)であることを強制します。

関数の引数の二変系(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) {
    /* ... */
}

// 脆弱ですが、便利で一般的です
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));

任意の引数とRest引数

互換性のために関数を比較する場合、任意と必須の引数は入れ替えることが可能です。 元の型に指定される余分な任意引数がエラーになることはありません。 また、対象の型で相当する引数の無い任意の引数はエラーになりません。

関数がRest引数を持つ場合、それは無限に連なる任意の引数のように扱われます。

これは型システムの観点から見ると脆弱ですが、ランタイム時の観点から見れば、任意の引数は大抵が強制されるものではありません。 ほとんどの関数で、その場所にはundefinedが渡されることと同義になるためです。

理解しやすいように、コールバックを受け取る一般的なパターンを例にとって説明します。 プログラマーからすれば実行内容を予測できますが、型システムからすると引数の数は分からないことになります。

function invokeLater(args: any[], callback: (...args: any[]) => void) {
    /* ... 'args'を使用したcallbackの実行 ... */
}

// 脆弱 - invokeLaterは、任意の数の引数を提供する"かも"しれない
invokeLater([1, 2], (x, y) => console.log(x + ", " + y));

// 分かりにくく(xとyは実際には必須)、 確かめることができない
invokeLater([1, 2], (x?, y?) => console.log(x + ", " + y));

オーバーロードを持つ関数

関数がオーバーロードを持つ場合、元の型の各オーバーロードが、対象の型の互換性に一致しなければいけません。 これによって、対象となる関数が元の関数として、全ての同じ状況下で呼び出し可能であることが保証されます。

Enum

Enumは数値(number)と互換性があり、数値はEnumと互換性があります。 別のEnum型のEnum値は互換性が無いと判定されます。 下記にその例を示します。

enum Status { Ready, Waiting };
enum Color { Red, Blue, Green };

let status = Status.Ready;
status = Color.Green;  //error

クラス

クラスはオブジェクトリテラル型とインターフェースと同じように動作しますが、1つだけ違いがあります。 それは静的型とインスタンス型の両方を持つということです。 クラス型の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メンバとprotectedメンバ

クラス内のprivateとprotectedのメンバは、それらの互換性に影響を与えます。 互換性のためにクラスのインスタンスをチェックする際に、 もしインスタンスがprivateメンバを含む場合、 対象とする型にも同じクラスから生じたprivateメンバが含まれていなければいけません。

protectedのメンバのインスタンスに対しても、同じことが適用されます。 これは、あるクラスがそのsuperクラスへの互換性の割り当てが可能ではあるが、 異なる継承関係から生じたものは、例え同じ形状をしたクラスであっても、不可であることを意味します。

ジェネリクス

TypeScriptは構造的型システムであるため、型パラメーターはメンバの型の一部として使用された際には、型の結果に対してのみ影響を与えます。

interface Empty<T> {
}
let x: Empty<number>;
let y: Empty<string>;

x = y;  // OK、yはxの構造に一致します

上記の例では、xyには互換性があります。 何故なら、これらの構造は型引数が識別されるような使い方をしていないからです。 次のようにして、Empty<T>にメンバを追加してみます。

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

これまで我々は「互換性(compatible)」を使用してきましたが、この用語は言語仕様として定義されていません。 TypeScriptでは互換性には、subtypeとassignmentの2つの種類が存在します。 これらの違いはsubtypeの互換性がassignmentを拡張して、anyへ、またはanyから、 そして各数値に対応するenumへ、またはenumからの割り当てを許可する規則がある点のみです。(翻訳に自信なし)

2つの互換性メカニズムを、状況に応じて使い分けます。 実用的な目的で言えば、型の互換性はimplementsextendsのケースであっても、 互換性が割り当てられることで記録されます。 詳細については、TypeScriptの仕様書を参照してください。

 Back to top

© https://github.com/Microsoft/TypeScript-Handbook

このページは、ページトップのリンク先のTypeScript-Handbook内のページを翻訳した内容を基に構成されています。 下記の項目を確認し、必要に応じて公式のドキュメントをご確認ください。 もし、誤訳などの間違いを見つけましたら、 @tomofまで教えていただければ幸いです。

  • ドキュメントの情報が古い可能性があります。
  • "訳注:"などの断わりを入れた上で、日本人向けの情報やより分かり易くするための追記を行っている事があります。