今までフロントエンド開発で Type を使っていたけど、AI に書かせると Interface を多用されて困惑したので、改めて整理する。 これまで複数の Next.js やら React のプロジェクトも関わってきましたが基本的にみんな Type を使っていたから使っていたから使っていた程度の認識でした。
結論
とりあえずの僕の結論は、次のようになりました。
- 理由がなければ type を使う
- ユニオン型や交差型、タプル型、条件付き型など「型計算」を扱いやすく、React の Props 定義やユーティリティ型との相性も良い。
- 普通の業務アプリケーション開発ではパフォーマンス上の問題もほとんど発生しないため、可読性・柔軟性の観点からも type を基準にするのが自然。
- クラスを使う場合(OOP 設計)や外部拡張が必要な場合は interface を使う
- interface はクラスの implements に馴染みやすく、OOP スタイルで書く場合はこちらの方が直感的。
- また、宣言マージやモジュール拡張ができるため、外部ライブラリの型を拡張したいとき(例:Express の Request にフィールドを追加する、styled-components の DefaultTheme を拡張するなど)は interface 一択。
- プロジェクトの方針に則るのが最優先
- Google などの大規模組織では「オブジェクト型は interface」と明確に決めている場合がある。
- チーム開発では「どちらを使うか」よりも「一貫して使っているか」の方がはるかに重要であり、コードベース全体の可読性・保守性を高める。
interface と type の基本的な定義と違い
interface
interface User {
id: number;
name: string;
email: string;
}
type
type User = {
id: number;
name: string;
email: string;
};
どちらも同じようにオブジェクトの型を定義できますが、細かな違いがある。
主な違い
1. 宣言のマージ(Declaration Merging)
interfaceは同じ名前で複数回宣言すると、自動的にマージされます。
interface User {
id: number;
name: string;
}
interface User {
email: string;
}
// 結果的に以下と同じになる
interface User {
id: number;
name: string;
email: string;
}
(正直この仕様やばすぎると思う...)
一方、typeは同じ名前で複数回宣言するとエラーになる。
type User = {
id: number;
name: string;
};
// エラー: Duplicate identifier 'User'
type User = {
email: string;
};
2. 継承の書き方
interface の継承
interfaceは他のinterfaceやtypeを継承可能。
interface Animal {
name: string;
}
interface Dog extends Animal {
breed: string;
}
// type からも継承可能
type Cat = {
meow: () => void;
};
interface PersianCat extends Cat {
furColor: string;
}
type の継承(交差型を使用)
typeはextendsキーワードは使えませんが、交差型(&)で同様のことが可能。
type Animal = {
name: string;
};
type Dog = Animal & {
breed: string;
};
3. ユニオン型とプリミティブ型
typeはユニオン型やプリミティブ型のエイリアスを作成できますが、interfaceはできない。
// type では可能
type Status = "loading" | "success" | "error";
type ID = string | number;
type UserId = string;
// interface では不可能
// interface Status = "loading" | "success" | "error"; // エラー
4. 条件型とマップ型
typeは条件型やマップ型などの高度な型操作が可能。
// 条件型
type NonNullable<T> = T extends null | undefined ? never : T;
// マップ型
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
// interface ではこれらは不可能
5. タプル型
タプル型はtypeでのみ定義可能。
// type では可能
type Coordinates = [number, number];
type RGB = [number, number, number];
// interface では直接的には不可能(配列として定義は可能)
interface CoordinatesInterface {
0: number;
1: number;
length: 2;
}
6. プロパティのオーバーライド
継承時のプロパティオーバーライドで、interfaceとtypeは異なる動作をします。
interface の場合
interface Animal {
name: string | number;
}
interface Dog extends Animal {
name: string; // より具体的な型に変更可能
}
type の場合(交差型)
type Animal = {
name: string | number;
};
type Dog = Animal & {
name: string; // 交差型により string & (string | number) = string
};
// しかし、矛盾する型を指定すると never になる
type Cat = Animal & {
name: boolean; // boolean & (string | number) = never
};
ただ Generics を使えば、typeでも同様のことが可能。
type Override<T, U> = Omit<T, keyof U> & U;
type Animal = {
name: string | number;
};
type Dog = Override<Animal, { name: string }>;
// => { name: string } // 期待どおり上書き
type Cat = Override<Animal, { name: boolean }>;
// => { name: boolean } // neverにならず、素直に boolean に上書き
7. マップ型(Mapped Types)
typeはマップ型を使用できますが、interfaceでは使用できません。
// type でのみ可能
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
type Partial<T> = {
[P in keyof T]?: T[P];
};
// interface では不可能
// interface ReadonlyInterface<T> = {
// readonly [P in keyof T]: T[P]; // エラー
// };
使い分けのガイドライン
業界標準の推奨事項
Google スタイルガイドでは以下のように推奨されています:
- プリミティブ型、ユニオン型、タプル型:
typeを使用 - オブジェクトの型定義:
interfaceを使用
These forms are nearly equivalent, so under the principle of just choosing one out of two forms to prevent variation, we should choose one. Additionally, there are also interesting technical reasons to prefer interface. That page quotes the TypeScript team lead: Honestly, my take is that it should really just be interfaces for anything that they can model. There is no benefit to type aliases when there are so many issues around display/perf. https://google.github.io/styleguide/tsguide.html#prefer-interfaces
// type を使う例
type Status = "loading" | "success" | "error";
type Point = [number, number];
type ID = string;
// interface を使う例
interface User {
id: ID;
name: string;
status: Status;
}
interface を使う場合
-
オブジェクトの形状を定義する場合
interface ApiResponse { data: any; status: number; message: string; } -
ライブラリや API の型定義で拡張可能性が必要な場合
// ライブラリ側 interface Config { apiUrl: string; } // ユーザー側で拡張 interface Config { timeout: number; } -
クラスの実装時
interface Drawable { draw(): void; } class Circle implements Drawable { draw() { // 実装 } }
type を使う場合
-
ユニオン型やプリミティブ型のエイリアス
type Theme = "light" | "dark"; type EventHandler = (event: Event) => void; -
条件型やマップ型などの複雑な型操作
type Partial<T> = { [P in keyof T]?: T[P]; }; -
タプル型
type Point = [number, number]; -
交差型による型の合成
type UserWithTimestamps = User & { createdAt: Date; updatedAt: Date; };
TypeScript コンパイラでの処理
内部での型の表現
TypeScript コンパイラはinterfaceとtypeを異なる方法で処理します:
interface の処理
interface User {
name: string;
age: number;
}
// コンパイラ内部では名前付きシンボルとして保存
// - シンボルテーブルに "User" として登録
// - 型チェック時に名前で参照
// - エラーメッセージでは型名が表示される
type の処理
type User = {
name: string;
age: number;
};
// コンパイラ内部では構造的に展開される
// - 型エイリアスとして処理
// - 必要に応じて型が展開される
// - 複雑な型の場合は完全に展開されることがある
型チェックのパフォーマンス
interface の利点
interface BaseConfig {
apiUrl: string;
}
interface UserConfig extends BaseConfig {
userId: string;
}
interface AdminConfig extends BaseConfig {
adminKey: string;
}
// 継承チェーンが効率的に処理される
// 各interfaceは独立したシンボルとして管理
type の場合
type BaseConfig = {
apiUrl: string;
};
type UserConfig = BaseConfig & {
userId: string;
};
type AdminConfig = BaseConfig & {
adminKey: string;
};
// 交差型の計算が必要
// 使用時に型の合成処理が発生
エラーメッセージの違い
interface のエラーメッセージ
interface User {
name: string;
age: number;
}
const user: User = { name: "Alice" };
// Error: Property 'age' is missing in type '{ name: string; }'
// but required in type 'User'.
type のエラーメッセージ
type User = {
name: string;
age: number;
};
const user: User = { name: "Alice" };
// Error: Property 'age' is missing in type '{ name: string; }'
// but required in type 'User'.
// 複雑な型の場合
type ComplexUser = {
name: string;
} & {
age: number;
} & {
email: string;
};
const complexUser: ComplexUser = { name: "Alice" };
// Error: Type '{ name: string; }' is missing the following properties
// from type '{ name: string; } & { age: number; } & { email: string; }':
// age, email
コンパイル時間への影響
大規模プロジェクトでの比較
// interface - 高速
interface ApiResponse<T> {
data: T;
status: number;
}
interface UserResponse extends ApiResponse<User> {
user: User;
}
// type - やや重い処理
type ApiResponse<T> = {
data: T;
status: number;
};
type UserResponse = ApiResponse<User> & {
user: User;
};
型の解決とキャッシュ
interface の型解決
interface Config {
url: string;
}
// 同じ名前のinterfaceは一度解決されるとキャッシュされる
// 宣言マージも効率的に処理される
interface Config {
timeout: number;
}
// 最終的にマージされた型が一度だけ計算される
type の型解決
type Config = {
url: string;
};
type ExtendedConfig = Config & {
timeout: number;
};
// 毎回交差型の計算が必要
// ネストが深くなると計算コストが増加
type DeeplyNestedConfig = ExtendedConfig & {
retries: number;
} & {
headers: Record<string, string>;
};
実際のパフォーマンス測定例
// 測定用のコード例
interface IUser {
id: string;
name: string;
email: string;
}
type TUser = {
id: string;
name: string;
email: string;
};
// 大量の型チェックが必要な場合
// interface: ~50ms
// type: ~65ms
// (実際の値はプロジェクトの規模による)
メモリ使用量
- interface: シンボルテーブルで効率的に管理
- type: 型の展開により追加のメモリが必要な場合がある
推奨事項
パフォーマンスの観点から:
- 大規模プロジェクト:
interfaceを基本とする - 継承の多い設計:
interfaceが有利 - 複雑な型操作:
typeでも問題なし(機能が必要) - ライブラリ開発:
interfaceで API を設計
TypeScript コンパイラの処理フロー
TypeScript コンパイラ(tsc)は以下のステップでinterfaceとtypeを処理します:
1. 字句解析(Lexical Analysis)
// ソースコード
interface User {
name: string;
}
type Status = "active" | "inactive";
// トークン化の結果
INTERFACE_KEYWORD, IDENTIFIER(User), LEFT_BRACE,
IDENTIFIER(name), COLON, STRING_KEYWORD, RIGHT_BRACE
TYPE_KEYWORD, IDENTIFIER(Status), EQUALS,
STRING_LITERAL("active"), PIPE, STRING_LITERAL("inactive")
2. 構文解析(Parsing)
AST(抽象構文木)の生成:
// interface の AST ノード
{
kind: SyntaxKind.InterfaceDeclaration,
name: { text: "User" },
members: [
{
kind: SyntaxKind.PropertySignature,
name: { text: "name" },
type: { kind: SyntaxKind.StringKeyword }
}
]
}
// type の AST ノード
{
kind: SyntaxKind.TypeAliasDeclaration,
name: { text: "Status" },
type: {
kind: SyntaxKind.UnionType,
types: [
{ kind: SyntaxKind.LiteralType, literal: { text: "active" } },
{ kind: SyntaxKind.LiteralType, literal: { text: "inactive" } }
]
}
}
3. バインディング(Binding)
シンボルテーブルの構築:
// interface のシンボル
Symbol {
name: "User",
flags: SymbolFlags.Interface,
declarations: [InterfaceDeclaration],
members: Map {
"name" => Symbol { name: "name", type: StringType }
}
}
// type のシンボル
Symbol {
name: "Status",
flags: SymbolFlags.TypeAlias,
declarations: [TypeAliasDeclaration],
aliasedSymbol: UnionType
}
4. 型チェック(Type Checking)
interface の型チェック
interface User {
name: string;
age: number;
}
const user: User = { name: "Alice" }; // エラー検出
// 型チェッカーの処理:
// 1. User シンボルを解決
// 2. オブジェクトリテラルの型を推論
// 3. 構造的互換性をチェック
// 4. 不足プロパティ(age)を検出
type の型チェック
type UserType = {
name: string;
age: number;
};
const user: UserType = { name: "Alice" }; // エラー検出
// 型チェッカーの処理:
// 1. UserType エイリアスを展開
// 2. 実際の型構造を解決
// 3. オブジェクトリテラルとの比較
// 4. 型の不一致を検出
5. JavaScript への変換(Emit)
TypeScript コードが JavaScript に変換される際の違い:
コンパイル前(TypeScript)
// interface の定義
interface User {
name: string;
age: number;
greet(): string;
}
// type の定義
type Config = {
apiUrl: string;
timeout: number;
};
// 実装
class UserImpl implements User {
constructor(public name: string, public age: number) {}
greet(): string {
return `Hello, I'm ${this.name}`;
}
}
const config: Config = {
apiUrl: "https://api.example.com",
timeout: 5000,
};
function processUser(user: User): void {
console.log(user.greet());
}
コンパイル後(JavaScript)
// interface は完全に削除される
// interface User { ... } → コンパイル結果に含まれない
// type も完全に削除される
// type Config = { ... } → コンパイル結果に含まれない
// 実装のみが残る
class UserImpl {
constructor(name, age) {
this.name = name;
this.age = age;
}
greet() {
return `Hello, I'm ${this.name}`;
}
}
const config = {
apiUrl: "https://api.example.com",
timeout: 5000,
};
function processUser(user) {
console.log(user.greet());
}
コンパイル時の処理の詳細
宣言マージの処理
コンパイル前
interface Window {
customProperty: string;
}
interface Window {
anotherProperty: number;
}
// 使用例
window.customProperty = "test";
window.anotherProperty = 42;
コンパイラ内部での処理
// 1. 最初の Interface Declaration を処理
// Symbol "Window" を作成、customProperty を追加
// 2. 二番目の Interface Declaration を処理
// 既存の "Window" Symbol を発見
// anotherProperty を既存のメンバーに追加
// 3. 最終的にマージされた型
interface Window {
customProperty: string;
anotherProperty: number;
// + 既存のWindowの全プロパティ
}
コンパイル後
// 型情報は削除され、実装のみ残る
window.customProperty = "test";
window.anotherProperty = 42;
継承の処理
interface の継承(コンパイル前)
interface Animal {
name: string;
makeSound(): string;
}
interface Dog extends Animal {
breed: string;
wagTail(): void;
}
class Labrador implements Dog {
constructor(public name: string, public breed: string) {}
makeSound(): string {
return "Woof!";
}
wagTail(): void {
console.log("Wagging tail happily");
}
}
コンパイラ内部での継承解決
// 1. Animal インターフェースのシンボル作成
AnimalSymbol {
members: {
name: StringType,
makeSound: FunctionType
}
}
// 2. Dog インターフェースの継承処理
DogSymbol {
baseTypes: [AnimalSymbol],
members: {
// Animal から継承
name: StringType,
makeSound: FunctionType,
// Dog 固有
breed: StringType,
wagTail: FunctionType
}
}
// 3. クラス実装の検証
// すべての必須メンバーが実装されているかチェック
コンパイル後
class Labrador {
constructor(name, breed) {
this.name = name;
this.breed = breed;
}
makeSound() {
return "Woof!";
}
wagTail() {
console.log("Wagging tail happily");
}
}
type の交差型処理
コンパイル前
type Timestamp = {
createdAt: Date;
updatedAt: Date;
};
type User = {
id: string;
name: string;
};
type UserWithTimestamp = User & Timestamp;
const user: UserWithTimestamp = {
id: "1",
name: "Alice",
createdAt: new Date(),
updatedAt: new Date(),
};
コンパイラ内部での交差型解決
// 1. 各 type の型情報を保存
TimestampType = ObjectType {
properties: {
createdAt: DateType,
updatedAt: DateType
}
}
UserType = ObjectType {
properties: {
id: StringType,
name: StringType
}
}
// 2. 交差型の計算
UserWithTimestampType = IntersectionType {
types: [UserType, TimestampType],
// 実際の使用時に以下に展開される
resolvedProperties: {
id: StringType,
name: StringType,
createdAt: DateType,
updatedAt: DateType
}
}
コンパイル後
const user = {
id: "1",
name: "Alice",
createdAt: new Date(),
updatedAt: new Date(),
};
型消去(Type Erasure)の詳細
TypeScript の最も重要な特徴の一つは、コンパイル時にすべての型情報が削除されることです:
削除される要素
-
型注釈
// コンパイル前 const name: string = "Alice"; // コンパイル後 const name = "Alice"; -
interface 定義
// コンパイル前 interface User { name: string; } // コンパイル後 // 完全に削除される -
type 定義
// コンパイル前 type Status = "active" | "inactive"; // コンパイル後 // 完全に削除される -
ジェネリクス
// コンパイル前 function identity<T>(arg: T): T { return arg; } // コンパイル後 function identity(arg) { return arg; }
残る要素
- 実際の値と実装
- クラス定義(ランタイムで必要)
- enum(値として使用される場合)
実践的な例
API レスポンスの型定義
// ベースとなるレスポンス型
interface BaseApiResponse {
status: number;
message: string;
}
// 成功時のレスポンス
interface SuccessResponse<T> extends BaseApiResponse {
data: T;
}
// エラー時のレスポンス
interface ErrorResponse extends BaseApiResponse {
error: string;
}
// ユニオン型で組み合わせ
type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;
コンポーネントの Props 定義
// 基本的なprops
interface BaseButtonProps {
children: React.ReactNode;
onClick: () => void;
}
// バリアント別のprops
type PrimaryButtonProps = BaseButtonProps & {
variant: "primary";
color?: never;
};
type SecondaryButtonProps = BaseButtonProps & {
variant: "secondary";
color: "blue" | "red" | "green";
};
// ユニオン型で組み合わせ
type ButtonProps = PrimaryButtonProps | SecondaryButtonProps;
実際の判断基準
複雑な選択に迷う場合は、以下の優先順位で決めることを推奨します:
- チームの規約: 既存のコードベースの統一性を優先
- Google スタイルガイド: オブジェクト型は
interface、その他はtype - 機能要件: 宣言マージや継承の必要性で判断
- 迷ったら
type: より制限が少なく、後から変更しやすい
ライブラリ開発での考慮事項
ライブラリを開発する場合は、拡張性を重視してinterfaceを選択することが多いです:
// ライブラリ側
export interface Config {
apiUrl: string;
timeout?: number;
}
// ユーザー側で拡張可能
declare module "your-library" {
interface Config {
customOption?: boolean;
}
}
まとめ
| 項目 | interface | type |
|---|---|---|
| オブジェクト型定義 | ✅ 推奨 | ✅ 可能 |
| ユニオン型/プリミティブ | ❌ 不可 | ✅ 推奨 |
| 宣言マージ | ✅ 可能 | ❌ 不可 |
| マップ型 | ❌ 不可 | ✅ 可能 |
| 継承 | ✅ extends | ✅ 交差型 |
| タプル型 | ❌ 不可 | ✅ 可能 |
| 条件型 | ❌ 不可 | ✅ 可能 |
推奨アプローチ:
- オブジェクトの型定義:
interface - プリミティブ、ユニオン、タプル:
type - 複雑な型操作:
type - ライブラリの拡張可能な型:
interface
最終的には、チームのコーディング規約やプロジェクトの要件に合わせて一貫性を保つことが最も重要です。