Edited at

TypeScriptのInterfaceとTypeの比較

こういうコンパイルエラーにぶち当たった。

Interfaceだとコンパイルエラーになったのが、Typeなら問題なく通る。

declare function numMapToVoid(obj: {[key: string]: number}): void;

// interfaceだとコンパイルエラー
interface IObj {
a: number
}
const iObj: IObj = {a: 1};
numMapToVoid(iObj); // エラー

// typeだと問題なし
type TObj = {
a: number
};
const tObj: TObj = {a: 1};
numMapToVoid(tObj); // OK

これは不思議...というのがきっかけで、TypeScriptのInterfaceとTypeの機能上の違いを調べてみた。


比較まとめ

TypeScript2.3.3で確認。([追記] 3.4.1でも確認)

Interface
Type

用途
クラスやオブジェクトの規格を定義
型や型の組み合わせに別名を付ける

継承
可能
交差型で同じことができる

同名要素の宣言
マージされる
エラー

Classへのimplement
可能
可能

交差型、共用体型、タプル型
不可能
可能

Mapped Types
不可能
可能

規定しないプロパティの扱い
他にもプロパティが存在しうるものとして扱う
存在しないものとして扱う

一応念のため、今回は単に機能や性質の差を調べただけで、使い分けの話はしない。両方で使えるからといって、どっちを使ってもいいというわけじゃない点に注意。


継承

両方可能。Interfaceで継承できるのはもちろん、交差型を使うとTypeでも継承めいたことができる。

// interfaceの継承

interface IPoint2D {
x: number;
y: number;
}
interface IPoint3D extends IPoint2D {
z: number;
}

// typeの継承もどき
type TPoint2D = {
x: number;
y: number;
}
type TPoint3D = TPoint2D & {
z: number;
}

ちなみにInterfaceがTypeを継承してもエラーにならない。

// OK: TypeがInterfaceを継承

type TIPoint3D = IPoint2D & {
z: number;
}

// OK: InterfaceがTypeを継承
interface ITPoint3D extends TPoint2D {
z: number;
}


同名要素の宣言

同名のInterfaceは全てマージされる。これを利用してInterfaceを拡張できる。

interface IPoint2D {

x: number;
y: number;
}
interface IPoint2D {
name: string;
}
const ok: IPoint2D = {x: 1, y: 1, name: 'p1'}; // OK
const ng: IPoint2D = {x: 1, y: 1}; // コンパイルエラー

一方で、Typeは複数宣言すると単純にエラーになる。

type TPoint2D = {

x: number;
y: number;
}
type TPoint2D = { // コンパイルエラー
name: string;
}

これは結構な違いで、Interfaceを使っていないライブラリにちょっと追加したいときに苦労するかもしれない。基本的になるべくInterfaceを使えというのはこれが理由だと思う。


Classへのimplement

両方可能。ClassのimplementsにTypeを入れてもエラーにならない。

// OK: interfaceのimplements宣言

class PointClass1 implements IPoint2D {
x: number;
y: number;
}

// OK: typeのimplements宣言
class PointClass2 implements TPoint2D {
x: number;
y: number;
}

[追記]

TypeやInterfaceは値ではないため、extendsはできない。

class PointClass1 extends IPoint2D { // エラー: インタフェースを拡張できません

}

class PointClass2 extends TPoint2D { // エラー: 型を値として使っています
}

ただ、逆にClassをimplementsすることはできる。

これは、Class宣言は同名Interfaceも同時に定義するため。

class Foo {

a: number;
}

class Bar implements Foo { // エラー: aを実装していません
}

Interfaceなので拡張できる。なぜかエラーは出ない。

interface Foo {

a: number;
}

class Foo { // OK
}

new Foo().a // number

正直、この辺の挙動はよくわからない。

interface Foo {

a: number;
}

class Foo implements Foo { // OK
}

class Bar implements Foo { // エラー: aを実装していません
}


交差型、共用体型、タプル型

Typeは複数の型全てを満たす型(交差型)、複数の型いずれかを満たす型(共用体型)を作ることができる。タプル型も作れる。Interfaceではできない。

// 交差型

// 引数としてaとbの両方のプロパティを持つオブジェクトを受け取る
type AB = {a: string} & {b: string};
function logAB(arg: AB) {
console.log(arg.a + arg.b);
}

// 共用体型
// 引数としてnumberかstringを受け取る
type NumOrStr = number | string;
function logLowerCase(arg: NumOrStr) {
if (typeof arg === 'number') {
console.log(arg);
} else {
// この時点でargはstring確定
console.log(arg.toLowerCase());
}
}

// タプル型
type Tuple = [number, string];
const tuple: Tuple = [1, 'a']; // OK


Mapped Types

TypeScript2.1で追加されたMapped TypesはTypeでしか使えない。

// interfaceでMapped Types

interface IPoint2D {
[key in 'x' | 'y'] : number; // コンパイルエラー
}

// typeでMapped Types
type TPoint2D = {
[key in 'x' | 'y'] : number; // OK
}

ちなみにインデックスシグネチャ型では両方とも使える。

// interfaceでインデックスシグネチャ

interface INumMap {
[key: string]: number; // OK
}

// typeでインデックスシグネチャ
type TNumMap = {
[key: string]: number; // OK
}

だったらMapped Typesも両方で使えて良さそうだけどなあ。


規定しないプロパティの扱い

ここから冒頭で述べた個人的本題。※推測混じりなので注意。

Interfaceを満たすオブジェクトは「少なくともその仕様の必要条件を満たしている」だけで、他にプロパティが存在する可能性があると想定しているのに対し、Typeに属するオブジェクトは「文字通りその型のオブジェクト」であり、他のプロパティの存在を考慮しなくなるようだ。

これだけではよくわからないと思うので、例として、valueが数値型のオブジェクト型を用意する。インデックスシグネチャ型として以下のように書ける。

// 値がnumberのオブジェクト

// (ここをtypeにしても同じになる)
interface NumMap {
[key: string] : number;
}

ここで、IPoint2Dのインタフェース型変数はNumMap型に代入できるだろうか?

interface IPoint2D {

x: number;
y: number;
}

// 代入してチェックしてみる
const iPoint: IPoint2D = {x: 1, y: 2};
const numMap: NumMap = iPoint; // コンパイルエラー

というわけで、これはコンパイルエラーになる。Typeでもやってみよう。

type TPoint2D = {

x: number;
y: number;
}

// 代入してチェックしてみる
const tPoint: TPoint2D = {x: 1, y: 2};
const numMap: NumMap = tPoint; // OK

こちらはコンパイルが通る。どういうこと?

試しに明らかにNumMapを満たさない変数を入れてみる。

// NumMapを満たさない値

const point = {x: 1, y: 2, foo: 'bar'};

const iPoint: IPoint2D = point; // これはOK (構造的部分型)
const numMap1: NumMap = iPoint; // コンパイルエラー

const tPoint: TPoint2D = point; // OK
const numMap2: NumMap = tPoint; // OK (でも実行時エラー吐きそう)

これでもTypeではコンパイルが通ることから、TPoint2D型変数となった時点で{foo: 'bar'}などが存在する余地が考慮されなくなったのだと推測される。Interfaceはその点厳密で、宣言していないあらゆるプロパティの存在を想定しているのでエラーを吐いてくれる、ということだろう。ただ正直、問題ないことが明らかなオブジェクトでもエラーになるのは不便ではある。

ちなみにMapped Typesでもプロパティを限定しなければ同様の挙動になる。

type NumMap2 = {

[P in string] : number;
}

const point = {x: 1, y: 2, foo: 'bar'};

const iPoint: IPoint2D = point;
const numMap1: NumMap2 = iPoint; // コンパイルエラー

const tPoint: TPoint2D = point;
const numMap2: NumMap2 = tPoint; // OK (でも実行時エラー吐きそう)

プロパティ名の範囲を限定すればコンパイルが通るようになる。実行時エラーも防げていい感じ。

type NumMap3<T> = {

[P in keyof T] : number;
}

const point = {x: 1, y: 2, foo: 'bar'};

const iPoint: IPoint2D = point;
const numMap1: NumMap3<IPoint2D> = iPoint; // OK

const tPoint: TPoint2D = point;
const numMap2: NumMap3<TPoint2D> = tPoint; // OK

[追記]

Nfm4yxnW8さんからはてブ上でコメントを頂きました。


Nfm4yxnW8

interfaceとtypeの使い分けは、tslintのinterface-over-type-literal を有効にしとけばいいと思う // interfaceじゃだめでtypeが通るの理由はここに書いてある https://github.com/Microsoft/TypeScript/issues/14736


リンク先を読むと、


  • Typeはオブジェクトリテラルの型表現でもある

  • Typeを厳密にすると、(明らかに追加のプロパティを持たない)オブジェクトリテラルをインデックスシグネチャ型に適用できなくなって不便

つまり、以下の場合にエラーを起こさないためらしい。

declare function numMapToVoid(obj: {[key: string]: number}): void;

numMapToVoid({a: 123}); // {a: number}型のオブジェクトリテラルを入れてもOK


感想

TypeScriptむずいっすね。

まとめとしてもう一回同じ表を貼っておく。

Interface
Type

用途
クラスやオブジェクトの規格を定義
型や型の組み合わせに別名を付ける

継承
可能
交差型で同じことができる

同名要素の宣言
マージされる
エラー

Classへのimplement
可能
可能

交差型、共用体型、タプル型
不可能
可能

Mapped Types
不可能
可能

規定しないプロパティの扱い
他にもプロパティが存在しうるものとして扱う
存在しないものとして扱う

機能的にはTypeの凄さが目につくなあ。

間違っていたら指摘お願いします。


参考