TypeScript

TypeScriptのInterfaceとType aliasの比較

More than 1 year has passed since last update.

こういうコンパイルエラーにぶち当たった。
Interfaceだとコンパイルエラーになったのが、Type aliasなら問題なく通る。

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

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

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

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

比較まとめ

TypeScript2.3.3で確認。

Interface Type alias
用途 クラスやオブジェクトの規格を定義 型や型の組み合わせに別名を付ける
継承 可能 交差型で同じことができる
Classへのimplement 可能 可能
同名要素の宣言 マージされる エラー
交差型、共用体型、タプル型 不可能 可能
Mapped Types 不可能 可能
規定しないプロパティの扱い 他にもプロパティが存在しうるものとして扱う 存在しないものとして扱う

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

継承

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

// interfaceの継承
interface IPoint2D {
  x: number;
  y: number;
}
interface IPoint3D extends IPoint2D {
  z: number;
}

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

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

// OK: Type aliasがInterfaceを継承
type TIPoint3D = IPoint2D & {
  z: number;
}

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

これはちょっとびっくり。

Classへのimplement

(実際にやるかはさておき)両方可能。ClassのimplementsにType aliasを入れてもエラーにならない。

// OK: interfaceのimplements宣言
class PointClass1 implements IPoint2D {
  x: number;
  y: number;
}

// OK: type aliasのimplements宣言
class PointClass2 implements TPoint2D {
  x: number;
  y: 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 aliasは複数宣言すると単純にエラーになる。

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

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

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

Type aliasは複数の型全てを満たす型(交差型)、複数の型いずれかを満たす型(共用体型)を作ることができる。タプル型も作れる。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 aliasでしか使えない。

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

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

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

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

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

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

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

ここから冒頭で述べた個人的本題。※推測混じりなので注意。
Interfaceを満たすオブジェクトは「少なくともその仕様の必要条件を満たしている」だけで、他にプロパティが存在する可能性があると想定しているのに対し、Type aliasに属するオブジェクトは「文字通りその型のオブジェクト」であり、他のプロパティの存在を考慮しなくなるようだ。

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

// 値がnumberのオブジェクト
// (ここをtype aliasにしても同じになる)
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 aliasでもやってみよう。

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 aliasではコンパイルが通ることから、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

感想

TypeScriptむずいっすね。
まとめとしてもう一回同じ表を貼っておく。

Interface Type alias
用途 クラスやオブジェクトの規格を定義 型や型の組み合わせに別名を付ける
継承 可能 交差型で同じことができる
Classへのimplement 可能 可能
同名要素の宣言 マージされる エラー
交差型、共用体型、タプル型 不可能 可能
Mapped Types 不可能 可能
規定しないプロパティの扱い 他にもプロパティが存在しうるものとして扱う 存在しないものとして扱う

機能的にはType aliasの凄さが目につくなあ。
間違っていたら指摘お願いします。

参考