📐

【結論】TypeScriptの型定義はtypeよりinterfaceを使うべき理由

に公開

はじめに

TypeScriptでコンポーネントのPropsやオブジェクトの型を定義するとき、typeとinterfaceのどちらを使うべきか、一度は悩んだことがあるのではないでしょうか。

巷では「どちらでも良い」「チームで統一されていればOK」といった意見もよく見かけます。
しかし、私は 明確な理由をもって「基本的にはinterfaceを使うべき」 だと主張します。

この記事では私の実体験で遭遇したReactのPropsの深刻なパフォーマンス問題を例に交えながら、なぜinterfaceが優れているのか、そしてtypeはどのような場面で使うべきなのかを解説します。

type aliasを使いたくなる魅力と、その裏に潜む罠

まず、なぜ多くの開発者がtypeを選びがちなのでしょうか。
それは、開発体験の良さにあります。

typeで定義した型は、VSCodeなどのエディタでホバーすると、最終的に解決された具体的な型情報がインラインで表示されます。

例として、ReactのPropsを定義してみましょう。

interfaceの型定義

export interface IconButtonProps extends HTMLAttributes<HTMLButtonElement> {
  icon: React.ReactNode;
}

export const IconButton = (props: IconButtonProps) => {
  // ...
};

interface ButtonWithIconProps

interfaceの場合は名前しか表示されませんでした。

type aliasの型定義

export type IconButtonProps = HTMLAttributes<HTMLButtonElement> & {
  icon: React.ReactNode;
};

export const IconButton = (props: IconButtonProps) => {
  // ...
};

type ButtonWithIconProps = ButtonBaseProps & IconProps & {
icon: React.ReactNode;
}

宣言した内容が表示されました。

このように、typeの場合、合成された型であっても、最終的な構造が一目でわかります。
私もこの「分かりやすさ」を理由に、業務では常にtypeを使うようにしていました。
しかし、これが後に大きな問題を引き起こすことになるのです。

type aliasが引き起こしたパフォーマンス地獄

私のチームが開発していたプロダクトが成長し、コードベースが大きくなってきたある日のことです。
なんの前触れもなく、エディタの型チェックが異常に遅くなりました。
文字を打つたびにCPUが唸り、一向にTypeScriptの型情報が表示されません。

さらに深刻だったのは、CI/CDでのビルド時間です。それまで1分程度で完了していたtscによる型チェックが、突然30分以上かかるようになったのです。

幸いなことに、この現象はまだマージ前の特定のブランチでのみ発生していたため、対象のブランチでの作業を一時停止し、他のブランチでの開発を続けることができました。

しかし、このブランチでの変更は重要な変更であり、早急にマージしたいものでした。

まったく心当たりがなく、チームメンバー全員で1週間以上、設定を見直したり、ライブラリをダウングレードしたりと、あらゆることを試しました。しかし、原因は一向に分かりませんでした。

万策尽きた私は、藁にもすがる思いで、ある仮説を試しました。「もしかして、typeが原因なのでは?」と。

プロジェクト全体の型定義を、機械的にtypeからinterfaceに一括置換する正規表現を書き、実行してみました。

すると、どうでしょう。あれだけ私たちを苦しめていたエディタの遅延はなくなり、ビルド時間は1分に戻ったのです。
犯人は、私たちが便利だと信じて使っていたtypeでした。

もし同様の問題でお困りの場合

ここに、VSCodeの一括置換に使える正規表現を用意して置きました!

Ctrl + Shift + FまたはCmd + Shift + Fで検索パネルを開き、正規表現を有効にしたうえで以下の設定で検索・置換できます。

Find: ^(\s*)(?:export\s+|declare\s+)*type\s+([A-Za-z_$][\w$]*(?:<[^>]*>)?)\s*=\s*(.+?)\s*&\s*\{\s*\r?\n([\s\S]*?)^\1\}\s*;?

Replace: $1interface $2 extends $3 {
$4$1}

複数交差している場合はこちらを繰り返し適用してください。

Find: ^(\s*interface\b[^{]*\bextends\b[^{}]*?)\s*&\s*

Replace: $1, 

なぜinterfaceはパフォーマンスに優れるのか?

理由は単純で、typeinterfaceでは型の計算タイミングが異なるからです。

type:即時評価(Eager Evaluation)

type型エイリアスです。
つまり、既存の型に別の名前を付ける機能です。
TypeScriptコンパイラはtypeを見つけると、その場で型を再帰的に解決・展開し、具体的な1つの型にしてから先に進みます。


先の例でホバー時に具体的な型が見えていたのは、まさにこの「即時評価」が行われている証拠です。

交差型 (&) が幾重にも重なった複雑な型定義では、この計算コストが指数関数的に増大し、型チェック全体のパフォーマンスを著しく低下させるのです。

interface:遅延評価(Lazy Evaluation)

一方、interfaceは新しい名前付きのオブジェクト型を宣言するものです。コンパイラはinterfaceを1つの名前(シンボル)として扱います。

interfaceで定義された型は、実際にそのプロパティが参照されるなど、型情報が必要になるまで、内部構造の完全な計算を遅延させます。 これが遅延評価です。

interfaceにホバーしても名前しか表示されないのは、このためです。

この遅延評価の仕組みにより、interfaceはどんなに複雑に継承されても、プロジェクトがどれだけ巨大になっても、パフォーマンスへの影響を最小限に抑えることができます。大規模なOSSライブラリのコードを見ると、そのほとんどがinterfaceで型定義されているのは、このスケーラビリティが理由です。

interfaceのほうがパフォーマンスに優れることが多いという事実は、TypeScriptの公式ドキュメントにも明記されています。

  • Using interfaces with extends can often be more performant for the compiler than type aliases with intersections

訳: interfaceを extends で拡張する方が、型エイリアスを intersection で組み合わせるよりも、コンパイラにとってはパフォーマンスの良いことが多いです。

https://www.typescriptlang.org/docs/handbook/2/everyday-types.html?utm_source=chatgpt.com#type-aliases

typeはいつ使うべきか?

では、typeは全く使うべきでないのでしょうか? いいえ、そんなことはありません。
interfaceでは表現できない型を定義する場合にのみ、typeを使うべきです。

具体的には、以下のようなケースです。

  1. Union型(合併型)を定義するとき

    type Status = 'success' | 'error' | 'loading';
    
  2. タプル型を定義するとき

    type UserTuple = [name: string, age: number];
    
  3. Mapped Typesなど、複雑な型操作をするとき

    type ReadonlyUser = {
      readonly [K in keyof User]: User[K];
    };
    
  4. プリミティブ型に別名を付けるとき

    type UserID = string;
    

オブジェクトの「形状」を定義する以外の、上記のようなケースでtypeは真価を発揮します。

おまけ OSSに学ぶ、interfaceにおける可読性を高める工夫

大規模なOSSライブラリでは、interfaceを使いながらも可読性を高めるために、いくつかの工夫がされていました。

JSDocコメントの活用

interface PageProps<AppRoute extends AppRoutes>にフォーカスした際、具体的な説明が記載されている

例えば、Next.jsの型定義では、interfaceに対してJSDocコメントを充実させることで、ホバー時に具体的な説明が表示されるようにしています。

また、interface名を具体的かつ説明的に命名することで、コードを読むだけでその役割が理解できるように工夫されています。

ユーティリティ型の活用

alt text

Honoの型定義では、ユーザーに公開する直前でSimplify<T>というユーティリティ型を使い、ホバー時に見やすくしています。

これは以下のように実装されている、継承に含まれる型を展開するための型ユーティリティです。

type Simplify<T> = { [K in keyof T]: T[K] } & {};

末端で一度だけの利用かつ、含まれるプロパティの数が少ない場合には有効です。

このように工夫することで、interfaceの利点を活かしつつ、開発体験の向上も図ることができます。

結論 基本はinterface、できないことだけtype

「どちらかに統一した方が良い」という主張を時々見かけますが、私はこれが間違いだと考えています。
typeinterfaceはそもそも役割が違うものであり、統一するべきではありません。

私たちが従うべきルールはシンプルです。

オブジェクトの形状を定義する際は、まずinterfaceを使う。interfaceで表現できない型(Union型など)を定義する必要がある場合に限り、typeを使う。

この指針に従うことで、TypeScriptの強力な型システムの恩恵を受けつつ、アプリケーションのパフォーマンスとスケーラビリティを将来にわたって維持できるでしょう。

typeのホバー時の分かりやすさは魅力的ですが、それはプロジェクトを蝕むパフォーマンス問題と引き換えになる可能性があることを、ぜひ覚えておいてください。

この記事が、type vs interface論争に1つの結論をもたらし、あなたの開発体験をより良いものにする一助となれば幸いです。

GitHubで編集を提案

Discussion