⚖️

TypeScript で type と interface どっち使うのか問題

2021/01/30に公開
5

はじめに

あくまで一個人の意見なので絶対的な解ではないというのと、どっちをデフォルトに選んでも普通にアプリケーション開発してて困ることはほぼほぼないと思うので、そこまで気を揉むことでもない、ということだけ最初に述べておいて意見をしたためます。

TL;DR

  • アプリケーション開発では基本的に type でおk
    • Declaration merging したい時だけ interface
  • ライブラリ開発のような使う側で拡張したい(Declaration merging したい)時は interface
  • とりあえずチームでどっちをデフォルトにするかは統一しといた方が気持ちいい

type と interface の違い

機能的にはそんなに大きな違いはなく、個人的に判断に関わるのは次の3つかなと思います。

  1. interface では Declaration merging がされる。type ではされない
  2. type では union 型や tuple 型が作れる。interface では作れない
  3. interface で定義したものは index signature で定義したオブジェクトに代入できない

1.interface では Declaration merging がされる。type ではされない

そもそも Declaration merging ってなんやねんという方のために説明すると、ざっくり言うと同じ名前の型(interface)定義が複数ある時に自動的に一つの定義にマージしてくれる機能のことです。

interface Person {
  name: string
}

interface Person {
  email: string
}

// name と email 両方必要になる
const john: Person = {
  name: "john",
  email: "hoge@example.com"
}

外部のライブラリの型なんかを名前を変えないまま拡張したい時などに便利な機能です。
ただデメリットとして、たまたまグローバルに定義されている型と同じ名前をつけてしまい拡張してしまうことが挙げられます。

このデメリットそんなに発動する機会ないのですが、Declaration merging が必要になる機会もほぼないと思うので、普段は type 使う方に倒しといた方がとりあえず safe なんじゃないのというのが個人的な意見です。

2.type では union 型や tuple 型が作れる。interface では作れない

こういったものです。

// Union
type Hoge = Fuga | Piyo

// tuple
type Hoge = [number, string]

こういうのを表現したい時は type を使うしかないです。

3.interface で定義したものは index signature で定義したオブジェクトに代入できない

そもそも index signature とはなんぞやという話ですが、次のようにオブジェクトの構造が規則に則っていることを規定できる型です。

// https://typescript-jp.gitbook.io/deep-dive/type-system/index-signatures から引用
let foo:{ [index:string] : {message: string} } = {};

/**
 * Must store stuff that conforms to the structure
 */
/** Ok */
foo['a'] = { message: 'some message' };
/** Error: must contain a `message` or type string. You have a typo in `message` */
foo['a'] = { messages: 'some message' };

ここで問題なのが、interface で定義したものは index signature を用いた型に代入できないということです。

一例を挙げてみると次のような {[key: string]: string} を引数を取るものがあるとして

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

上記の obj に対応したものを、 type と interface それぞれで定義して代入してみようと思います。

type だと問題なくコンパイルできます。

type TestObjectType = {
  text: string,
}

const testObject: TestObjectType = {
  text: "mozi"
};

test(testObject) // 問題なし

ですが interface だと Index signature is missing in type 'TestObjectInterface'.ts(2345) というエラーが出ます。

interface TestObjectInterface {
  text: string,
}

const testObject: TestObjectInterface = {
  text: "mozi"
};

// Index signature is missing in type 'TestObjectInterface'.ts(2345)
test(testObject)

interface の場合は先ほど紹介した Declaration merging を待つため、型の中身が確定していません。今見えてる範囲では {[key: string]: string} を満たしていても、拡張されて number のプロパティが追加されるかもしれません。

あまり頻度は高くないかもしれないですが、これも interface を使う一つのデメリットなのかなと思います。

コメントを受けての追記

というのを interface のデメリットとして挙げましたが、どうも interface の挙動の方がより堅牢で私の解釈が間違っていたようです。

詳しくはこの記事の acomagu さんのコメントを参照いただければと思いますが、 index signature の型のオブジェクトに対して type で定義したオブジェクトでは index signature で定義した型でないプロパティも代入できてしまうようです。
https://zenn.dev/seya/articles/aa94166c977280#comment-9ec4de4f5c65a9

まとめ

以上から type に統一しておいた方が困ることは少なくなるのではと思っているので、個人的には type をデフォルト推しです。

参考にした記事・動画

Discussion

acomaguacomagu

interface の場合は先ほど紹介した Declaration merging を待つため、型の中身が確定していません。今見えてる範囲では {[key: string]: string} を満たしていても、拡張されて number のプロパティが追加されるかもしれません。

自分は少し違うと考えています。この場合単に(Declaration merging は関係なく) interface の挙動のほうが安全で、type の挙動は安全性を犠牲にして(便利さを取って)いると思っています。

例えば

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

type A = {
    text: string;
};

type B = {
    text: string;
    num: number;
};

const b: B = {
    text: "abc",
    num: 1,
};

const a: A = b;

test(a);

このコードは test の引数に num: 1 が入ってしまっているにも関わらず、コンパイルが通ってしまいます。interface を使っていればこれを回避できます。

Typescript の Structual Subtyping の性質(type/interfece ともに定義されていないフィールドに関しては何でも受け入れます)を考えれば、interface の挙動のほうが自然に感じます。

ちなみにこの挙動に関しては GitHub の Issue で議論されています: https://github.com/microsoft/TypeScript/issues/15300

seyaseya

ご指摘ありがとうございます。
なるほど…確かにinterfaceの挙動の方が安全に感じますね。記事の内容は後ほど修正しようと思います。

イシューのリンクもありがとうございます!

yprestoypresto

このコードは test の引数に num: 1 が入ってしまっているにも関わらず、コンパイルが通ってしまいます。interface を使っていればこれを回避できます。

type Atype Bがそれぞれ interfacetype かに関わらず、 const a: A = b は成功するので、 num: 1 がエラーなく test() に入ってしまいます。安全側に倒すなら type Ainterface B のときに A = b がエラーになるべきと思いますが、現状そうなっていません。

ただ、オブジェクトリテラル型 { text: string } がtextのみ持つ型の表現である(下記参照)以上、{ text: string } に対して { text: "abc", num: 1 } も飛んでくる引数等では、interfaceを使っておく方がより安全と言えると思います。


整理すると下記のように理解しています。

  • type MappedType = { [key: string]: string } :全てのプロパティに対する値がstringである型
  • interface IA { text: string }最低限textというプロパティを持つ型
  • type A = { text: string }textというプロパティのみを持つ型(オブジェクトリテラル型)

type Aはtext: string以外のプロパティを持っていない(はずな)ので、MappedTypeに代入しても問題ないですが、interface IAは他のプロパティを持ちうるので代入できません。 Index signature is missing in type はわかりづらいので別のエラーメッセージになると良さそうですよね。

オブジェクトリテラル型は本来 const foo = { text: 'a' } と書いた時のfooの型なので、実際の値は他のプロパティを持たない想定です。よって型のプロパティを全て見ることでindex signature(この場合は [key: string]: string )が判定できます(checker.tsisObjectTypeWithInferableIndex() を参照)。

(ちなみにtypeがガバガバでよくない型なのではなく、オブジェクトリテラル型にtype Aという別名をつけている状態です。)

HimenonHimenon

この方は interface 派。個人的には拡張性云々に関しては interface の extends は type の A & B でマージするのと変わらないのでそんなに差はないと思っておる

今後状況は変わるかもしれませんが、2つ以上の型の拡張に関してはtype aliasによる交差型を作成するよりも、interfaceのextendsを利用するほうがビルド時のキャッシュが効くとWikiに記載があります。ケースによってはinterfaceのほうがビルドパフォーマンスの観点からすると優れている場合があると考えられます。

https://github.com/microsoft/TypeScript/wiki/Performance#preferring-interfaces-over-intersections

seyaseya

パフォーマンスは観点から抜けていました
Twitterの方でもコメントありがとうございました!