🌝

TypeScriptにおけるオブジェクトの型定義

に公開

はじめに

普段TypeScriptで開発する際、オブジェクトの型定義には「インターフェース」、もしくは「型エイリアス」のどちらかを使うかと思います。私は型エイリアスを脳死で使っていましたが、改めてどっちを使うべきなのかについて調査してみました。

型エイリアスとは

型に対して別の名前をつけて宣言することを指します。オブジェクトだけでなく、プリミティブ型やUnion型などに対しても別名をつけることができます。

type Str = string;
type StringOrNumber = string | number;
type User = {
  name: string;
  age: number; 
}

型エイリアスを定義して使用することでコードの記述量が減ったり、型定義箇所が一か所にまとめられるため、保守性を向上させることができます。

インターフェースとは

クラスが実装すべきフィールドやメソッドを定義した型のことを指します。

interface Person { 
  name: string;
  speak(): string;
}

TypescriptのインターフェースはJavaやPHPのインターフェースと違って構造的部分型のため、インターフェースと実装関係がないオブジェクトに対しても型注釈として使用ができます。

オブジェクトを型定義する際の二つの違い

1. 継承

インターフェース

継承ができます。

// DogはAnimal型のプロパティも持った型となる
interface Animal = { type: string; }
interface Dog extends Animal { breed :string; } // { type: string; breed: string }

型エイリアス

継承はできませんが、交差型(&)を使って継承と似たことができます。

type Animal = { type: string; };
type Dog =  Animal & { breed :string; } // { type: string; breed: string };

2. プロパティの上書き

インターフェース

継承を使って、継承先で継承元と同じプロパティを定義すると型の上書きが可能です。ただし、元の型に代入できるものでないと上書きされません。
例えば、以下の例では継承元のプロパティの型がanyのため、継承先のstringが代入できます。

interface A {
  name : any;
}
interface B extends A {
  name : string;
}

しかし、こちらの例では継承先でプロパティの上書ができません。なぜならnumberstringは代入できないためです。

interface A {
  name : number;
}
interface B extends A {
  name : string; // エラー
}

型エイリアス

プロパティの上書きはされず、内部的に型が計算されます。
なお、内部的な計算に矛盾があってもコンパイルエラーとして検出されず、場合によっては意図しない型が上書きされてしまう場合があります。
例えば、以下の例でtype Anameプロパティはnumbertype Bnameプロパティはstringとなっていて交差型での拡張時に矛盾が生じていますがエラーをはきません。また、内部的に計算された結果type Bnameプロパティの型はnever型になってしまいます。

type A =  {
  name : number;
};
type B = A & {
  name : string;
};
// 最終的な型
type B = {
  name :never; // 交差型の計算に矛盾が生じた場合はnever型になる。
};

3. 同名の型を定義

インターフェース

同名のインターフェースを定義すると内容がマージされます。そのため、同一ファイル内で誤って同名のインターフェースを定義してしまうと、意図しない拡張が起こる場合もあります。

interface A { name: string; }
interface A { age: number; }
// 最終的な型
interface A { 
  name: string; 
  age: number;
}

なお、同名の型で同名のプロパティを定義した場合はエラーを吐いてくれます。

interface A { name: string; }
interface A { name: number; } // エラー

型エイリアス

型エイリアスは同名の型エイリアスが定義するとエラーを吐いてくれます。

type A =  { name: string; };
type A =  { age: number; }; // エラー

4. Mapped Typeの使用可否

Mapped Typeはオブジェクトのキーの型を動的に指定できる仕組みです。

インターフェース

使用できません。

型エイリアス

以下の通り使用できます。

type Language = "en" | "ja";
type Text = {
  [key in Language]: string;
};

違いのまとめ

インターフェース 型エイリアス
1.継承 可能 可能(交差型を使用)
2.プロパティの上書き 可能(代入可能な型に限る) 可能(意図しない上書きが発生する可能性あり)
3.同名の型を定義 可能(プロパティがマージされる。同名プロパティが存在したらエラーを吐く。) 不可
4.Mapped Type 不可 可能

基本的にできる事はほとんど同じですが、気になった違いとしては以下3つかなと思います。

  • 型エイリアスはMapped Typeが使える。
  • 型エイリアスはプロパティが意図せず上書きされる危険性がある
  • インターフェースは勝手に拡張される危険性がある。

その他ご意見

型エイリアス派

  • インターフェースは意図しない型の拡張が起きてしまう可能性があるため危ない。
  • 型エイリアスはオブジェクトの型定義以外にも使用する機会が多く、見た目の一貫性を考えて統一した方がいいから。
  • 宣言時の文字の長さが短い。
  • JavaやPHPに慣れた開発者からするとTypeScriptのインターフェースは挙動が違うため混乱しやすい。

インターフェース派

  • 型エイリアスは意図しないプロパティの上書きが起こりうるため危ない。
  • インターフェースによる継承は型エイリアスの交差型による継承と違い、ビルド時に型同士の関係がキャッシュされるため、パフォーマンス的に優れている。
  • 型エイリアスは特定のケースで注意すべき挙動があり、インターフェースであれば回避できる。

結論

オブジェクトの型定義には基本的にインターフェースを使用して、Mapped Typeを定義したい場合のみ型エイリアスを使用するのが良さそうだと思いました。
理由としては、

  • 型エイリアスで継承(交差型)した際のプロパティ上書きが怖い。
  • 型エイリアスは特定のケースで注意すべき挙動がある。
  • 型エイリアスよりビルド時のパフォーマンスが優れているため。
  • googleやmicrosoftは上記理由などからインターフェースを推しているため。
  • インターフェイスが意図せず拡張されうる危険性については、基本気をつけていれば大丈夫そうだと思ったため。

参考

https://typescriptbook.jp/reference/object-oriented/interface/interface-vs-type-alias
https://blog.logrocket.com/types-vs-interfaces-typescript/
https://zenn.dev/seya/articles/aa94166c977280
https://github.com/microsoft/TypeScript/wiki/Performance#preferring-interfaces-over-intersections
https://typescript-jp.gitbook.io/deep-dive/type-system/interfaces

Discussion