TypeScriptのinterfaceとtype:概念から使い分ける
どちらが優れているかを先に決めるのではなく、「境界の約束」と「取り得る値の表現」という役割から、選択の理由を説明できるようになる
型とは:値の集合と操作の約束
プログラムは数値、文字列、真偽値、オブジェクトなどの値を扱います。型は、それらに貼る単なる名前ではありません。初学者向けには、型を「その場所に入り得る値の集合」と考えると理解しやすくなります。booleanならtrueとfalse、'idle' | 'running'なら列挙した2つだけが集合に含まれます。
値の範囲が決まると、安全に使える操作も決まります。numberには数値計算やtoFixedが使え、stringにはtoUpperCaseが使えます。string | numberのように複数の可能性がある間は、どちらであるかを絞り込むまで、一方にしかない操作は使えません。
1. 取り得る値を限定する
型は、受け入れる値と除外する値の境界を表します。unionを使えば、候補をさらに小さな集合へ絞れます。
boolean = true | false
2. 安全な操作を決める
コンパイラは値の型を手掛かりに、その時点で安全だと分かるプロパティや操作だけを許可します。
number → +, -, toFixed(...)
3. 必要な構造を表す
オブジェクト型は、値が持つべきプロパティと各プロパティの型を表します。特定のクラス名ではなく構造を検査します。
{ id: string; active: boolean }
type TaskStatus = 'idle' | 'running' | 'stopped' let currentStatus: TaskStatus = 'idle' currentStatus = 'running' // currentStatus = 'unknown' // エラー: TaskStatus に含まれない function formatValue(value: string | number): string { if (typeof value === 'number') { return value.toFixed(1) // number の操作 } return value.toUpperCase() // string の操作 } type UserRecord = { id: string active: boolean } const user: UserRecord = { id: 'u-1', active: true }
TypeScriptのコンパイラは、代入、関数の引数、返り値、プロパティへのアクセスなどで、実際の値の型と期待される型を比較します。矛盾があれば実行前にエラーとして知らせます。型注釈を書いていない場所でも、初期値や処理の流れから型を推論し、その知識を更新します。
型は実行中の値そのものではなく、その値についてコンパイラが持つ知識です。その知識によって、あり得ない値や安全でない操作をコードの実行前に見つけやすくします。
型検査と実行時の入力検証は別
TypeScriptの型情報は原則としてコンパイル後に消えます。API、JSON、フォームから届く値が型注釈どおりかを実行時に確かめることはできません。外部入力には、型とは別にruntime validationが必要です。
論争の前に、比較している言葉を分ける
「TypeScriptではinterfaceとtypeのどちらを使うべきか」という議論では、同じオブジェクト形状を両方で書けるため、好みや流派の問題に見えがちです。しかし、インターフェースという設計概念と、型という概念は同じ階層の言葉ではありません。
型は、ある値が取り得る範囲と、その値に許される操作を分類する一般的な概念です。インターフェースはその中でも、オブジェクトが境界の外へ何を公開し、利用者に何を約束するかを表す概念です。一方、TypeScriptのtypeキーワードは新しい種類の型を作る命令ではなく、型式に名前を付けるtype aliasの宣言です。
インターフェース:境界の約束
利用者から見えるプロパティや操作を示し、内部実装を知らなくても、その役割を利用できるようにします。中心にある問いは「この相手は何を約束するか」です。
execute(command): void
型:取り得る値の範囲
文字列、数値、オブジェクト、関数、タプル、unionなど、どの値を受け入れ、どの値を除外するかを表します。中心にある問いは「どの値が有効か」です。
'idle' | 'running' / readonly [number, number]
TypeScriptのinterface宣言も型を作ります。したがって実際の比較は「インターフェースか型か」ではなく、「オブジェクト型をinterface宣言で名付けるか、型式をtype aliasで名付けるか」です。
なぜ両方で同じ形を書けるのか
TypeScriptは、型の名前や継承宣言ではなく、値が持つメンバーの構造で互換性を判断する構造的型付けを採用しています。必要なプロパティとメソッドが揃っていれば、interfaceで名付けた型とtype aliasで名付けたオブジェクト型の間でも値を渡せます。
interface InterfaceUser { id: string name: string } type AliasUser = { id: string name: string } const user: InterfaceUser = { id: 'u-1', name: 'Ada' } // 名前ではなく構造が同じなので代入できる const sameShape: AliasUser = user type Runnable = { run(): void } // implements の右側に type alias も書ける class TaskRunner implements Runnable { run() { /* 処理を実行する */ } }
このため、オブジェクトの形を記述するだけなら両者の能力は大きく重なります。implementsもinterface専用ではなく、メンバーが静的に確定するオブジェクト型のtype aliasをクラスがimplementsすることもできます。
書けるかどうかだけを基準にすると、多くの場面で答えは「どちらでも書ける」です。使い分けには、構文の能力ではなく、その名前にどの設計意図を持たせるかを見る必要があります。
interfaceが伝えやすい意図:役割と公開境界
外部の利用者に公開するオブジェクトAPI、複数の実装が満たす役割、クラスが提供すべき操作の集合にはinterfaceがよく合います。宣言を読む人に「これは内部データの都合ではなく、利用側へ向けた約束だ」と伝えやすいからです。
interface VehicleController { steer(direction: number): void accelerate(amount: number): void brake(amount: number): void } class SimulatorController implements VehicleController { steer(direction: number) { /* 仮想の車輪を動かす */ } accelerate(amount: number) { /* 仮想速度を上げる */ } brake(amount: number) { /* 仮想速度を下げる */ } } function testDrive(controller: VehicleController) { controller.accelerate(0.4) controller.steer(0.2) controller.brake(0.3) }
VehicleControllerは、実車、シミュレーター、テスト用の偽物が共通して提供する操作を定めます。利用側は具体的なクラスではなく、この境界に依存できます。interfaceのextendsを使えば、関連するオブジェクト契約を段階的に拡張する意図も明示できます。
ただし、implementsと書きたいから必ずinterface、という言語上の制約はありません。type aliasでも同じ検査ができる場合があります。ここでinterfaceを選ぶ理由は、主に境界・役割・拡張可能な契約という意図を表すためです。
type aliasが必要になる場面:値の組み合わせを表す
type aliasは、オブジェクトだけでなく、primitive、union、tuple、関数型、intersection、template literal type、mapped type、conditional typeなど、任意の型式に名前を付けられます。「有効な値の集合」を組み立てることが中心なら、typeが自然です。
type VehicleId = string type Position = readonly [x: number, y: number] type DriveCommand = | { kind: 'steer'; direction: number } | { kind: 'accelerate'; amount: number } | { kind: 'brake'; amount: number } type CommandHandler = (command: DriveCommand) => Promise<void> type ReadonlyFields<T> = { readonly [Key in keyof T]: T[Key] }
DriveCommandのunionは、命令が3種類のどれかであり、それぞれに必要なデータが違うことを表します。Positionは要素の順序と個数を持つtupleです。これらはオブジェクトの公開境界というより、値が取り得る形そのものの定義です。なお、type aliasは別名なので、同じ構造から公称的に区別された新しい型が自動的に生まれるわけではありません。
二者択一ではなく、一緒に使う
実際の設計では、interfaceとtypeは競争相手ではありません。外部へ公開するオブジェクトの能力をinterfaceで表し、その操作へ渡すデータの選択肢をtype aliasで表すと、両方の概念がそれぞれの役割を持ちます。
// 取り得る命令の集合は type で表す type DriveCommand = | { kind: 'steer'; direction: number } | { kind: 'accelerate'; amount: number } | { kind: 'brake'; amount: number } // 外部へ公開する操作の約束は interface で表す interface VehicleController { execute(command: DriveCommand): void } class SimulatorController implements VehicleController { execute(command: DriveCommand) { switch (command.kind) { case 'steer': return this.steer(command.direction) case 'accelerate': return this.accelerate(command.amount) case 'brake': return this.brake(command.amount) } } private steer(direction: number) { /* ... */ } private accelerate(amount: number) { /* ... */ } private brake(amount: number) { /* ... */ } }
この例では、DriveCommandが有効な命令の集合を閉じたunionとして列挙し、VehicleControllerがその命令を実行できるという境界の約束を示します。新しい実装を追加する軸と、新しい命令を追加する軸を区別して考えられます。
「誰が何を約束するか」にはinterface、「どの値の組み合わせが有効か」にはtype。この問いの違いが見えれば、使い分けは流派ではなく設計判断になります。
実際に異なる点:宣言を再び開けるか
interfaceは、同じ名前の宣言を複数の場所に書くと、互換性のあるメンバーが一つの宣言へマージされます。type aliasは同じ名前で再宣言できません。これは単なる記法差ではなく、型を後から拡張する設計を許すかどうかの違いです。
interface Logger { info(message: string): void } interface PluginContext { logger: Logger } // 同じ名前の宣言が既存の interface に追加される interface PluginContext { locale: 'ja' | 'en' } const context: PluginContext = { logger: console, locale: 'ja' } type PluginOptions = { debug: boolean } // type PluginOptions = { locale: string } // エラー: Duplicate identifier 'PluginOptions'
interface:宣言を再び開ける
ライブラリの型定義やmodule augmentationで、既存の公開契約へ利用側がメンバーを追加できます。拡張点を提供したいAPIでは強みになります。
type:同名で再宣言できない
宣言後に同じ名前へ暗黙にメンバーが加わりません。ただし、intersectionや別のaliasを使って新しい型式を明示的に組み立てることはできます。
公開ライブラリの拡張点では宣言マージが役立ちます。一方、意図しない同名interfaceがマージされる可能性もあるため、アプリケーション内部で常に開放性が望ましいとは限りません。開けること自体を目的にせず、拡張主体が誰かを考えます。
既存の形を広げる場合も、interfaceはextends、type aliasはintersectionを使えます。ただし、同名プロパティが衝突したときの扱いは同じではありません。見た目の短さだけでなく、衝突をどこでエラーとして発見したいかも判断材料になります。
使い分けの判断表
絶対規則ではなく、表現したい意図から始めるための実務的な目安です。プロジェクト内で既に一貫した規約があり、どちらでも表現できる場合は、その規約を優先する方が読みやすくなります。
| 表現したいもの | 第一候補 | 理由 |
|---|---|---|
| 外部公開するオブジェクトAPIやサービス境界 | interface | 利用者への約束、実装から独立した役割、extendsによる契約の系列を伝えやすい。 |
| クラスが果たす役割や差し替え可能な実装 | interface | implementsと組み合わせたときに、クラスが満たす能力の意図を読み取りやすい。typeでも検査可能な場合はある。 |
| union、tuple、primitiveの別名、関数型 | type | オブジェクト境界に限られない任意の型式へ名前を付けられる。 |
| mapped type、conditional type、template literal type | type | 既存の型から新しい型式を計算・合成する表現はtype aliasの領域になる。 |
| 内部だけで使う単純なオブジェクト形状 | either | 両方で表現できる。拡張方針、既存規約、名前が示す意図を優先する。 |
| 宣言マージやmodule augmentationが必要な公開型 | interface | 同名宣言を安全に統合できることが必要条件になる。 |
迷ったら問いを変えます。「どちらが人気か」ではなく、「境界の相手に何を約束する名前か」「有効な値の集合をどう組み立てる名前か」を考えます。前者ならinterface、後者ならtype、どちらでもない単純な形ならプロジェクトの一貫性を優先します。
よくある誤解
interfaceを使えば公称型になるわけではありません。TypeScriptは基本的に構造で互換性を判断するため、明示的にimplementsしていない値でも必要なメンバーが揃えば適合します。
implementsの右側はinterfaceだけではありません。静的にメンバーが確定するオブジェクト型のtype aliasも使えます。interfaceを選ぶ理由は、構文上の必須条件ではなく設計意図にあります。
typeが新しくinterfaceが古い、あるいは片方が常に高機能という関係ではありません。重なる能力と、それぞれにしか表せない・できない領域があります。
interfaceもtype aliasも実行時の入力検証にはなりません。TypeScriptの型情報は原則としてコンパイル後に消えるため、外部入力には別途runtime validationが必要です。
まとめ
型は有効な値と操作を分類する一般概念。インターフェースは、オブジェクトが境界の外へ公開する約束という設計概念。
TypeScriptのinterface宣言も型を作り、typeキーワードは任意の型式へ別名を付ける。議論しているのは型と非型の違いではない。
構造的型付けのため、単純なオブジェクト形状は両方で表現できる。implementsもinterface専用ではない。
公開境界・役割・宣言マージにはinterface、union・tuple・型計算にはtypeが自然。両者は同じ設計の中で併用できる。
どちらでも書ける場面では、優劣の論争より、拡張方針、既存規約、その名前が読者へ伝える意図を優先する。
公式資料
言語仕様に関する説明は、TypeScript公式Handbookの以下の項目に基づいています。