TypeScript基礎講座 #3 interfaceとtype alias

読了 9分

前回は基本の型を整理しながら、オブジェクト型をインラインで書きました。ところが同じ形を複数の箇所で使うときに毎回インラインで書くと、コードはあっという間に汚くなります。今回は、オブジェクトと関数の型の形に名前を付けて再利用する2つの道具 — interfacetype aliasを扱います。

インライン型の限界 #

次のコードを見てください。

インライン型の繰り返し
function logUser(user: { id: string; name: string; email: string }): void {
  console.log(user.name);
}

function saveUser(user: { id: string; name: string; email: string }): void {
  // ...
}

const me: { id: string; name: string; email: string } = {
  id: 'u-1', name: '太郎', email: 'taro@example.com',
};

{ id: string; name: string; email: string }が3回繰り返されています。フィールドを1つ追加するなら3か所すべてを直す必要があり、タイプミスや漏れがあると型チェックがずれてしまいます。

解決策 — この形に名前を付けます。

type alias #

typeキーワードで型に別名を付けることができます。

type alias
type User = {
  id: string;
  name: string;
  email: string;
};

function logUser(user: User): void {
  console.log(user.name);
}

function saveUser(user: User): void {
  // ...
}

const me: User = {
  id: 'u-1', name: '太郎', email: 'taro@example.com',
};

ずっとすっきりしますね。Userという名前1つで、同じ形を複数の箇所で指せるようになります。

typeオブジェクトだけでなくどんな型にも別名を付けられます。

さまざまなtype alias
type ID = string;                              // プリミティブ型にも
type Point = [number, number];                 // タプル
type Color = 'red' | 'green' | 'blue';         // union
type Maybe<T> = T | null;                      // ジェネリクス (#6で)
type Callback = (err: Error | null, value: string) => void;  // 関数

命名規約はたいていPascalCase(UserOrderItem)です。

interface #

同じオブジェクト型をinterfaceキーワードでも表現できます。

interface
interface User {
  id: string;
  name: string;
  email: string;
}

function logUser(user: User): void {
  console.log(user.name);
}

上のtype aliasとほぼ同じ結果です。

interfaceオブジェクトと関数の形にしか使えない点がtype aliasと違います(プリミティブ型やunionには使えません)。それ以外はほぼ同じように動作します。

interface vs type — どちらを使うべき? #

もっとも頻繁に受ける質問です。結論から言うと、こうです。

ほとんどの場合は同じように動作する。小さな違いはあるが、最初は気にしなくてよい。

ほとんどのチームは次の2つのうちいずれかを選んで一貫して使います。

  • type 優先 — どこでもtypeを使い、オブジェクトにもtypeを使う。理由: 一貫性。React/Next.jsコミュニティはこちら寄り
  • interface 優先 — オブジェクトにはinterface、それ以外はtype。理由: オブジェクトによりよく合い、わずかな追加機能(宣言マージ)がある

このシリーズではtype優先スタイルで進めます。理由は(1)表現力が広く、すべてのケースを同じキーワードで扱えること、(2)最近のReact/TSコミュニティの多数派の選択であることです。ただしinterfaceにも慣れておく必要があります — ライブラリのコードを読むときによく出会いますから。

細かな違い #

1. 宣言マージ (declaration merging) #

interfaceは同じ名前で複数回宣言されると自動でマージされます。

interfaceの宣言マージ
interface User {
  name: string;
}

interface User {
  age: number;
}

const u: User = { name: '太郎', age: 30 };  // ✓ マージされて両方必要

typeは同じ名前で2回宣言するとエラーになります。

type の重複宣言は不可
type User = { name: string };
type User = { age: number };  // 🚫 エラー

宣言マージは外部ライブラリの型を拡張するときに便利です(WindowExpress.Requestなど)。日常コードではほとんど使いませんが、たまに必要になる強力な機能です。

2. 拡張の文法 #

interfaceはextendsで継承する形:

interfaceの拡張
interface Animal {
  name: string;
}

interface Dog extends Animal {
  breed: string;
}

const d: Dog = { name: 'ポチ', breed: '柴犬' };

typeは&(intersection)で合成する形:

type intersection
type Animal = { name: string };

type Dog = Animal & { breed: string };

const d: Dog = { name: 'ポチ', breed: '柴犬' };

ほぼ同じ結果ですが、微妙な違いがあります。衝突するケースでは動作が変わることがあり(interfaceはエラーで止めてくれる一方、&neverにしてしまうなど)、日常的な使い方では大きな違いは感じません。

3. typeだけで可能なもの #

union、tuple、関数型、マップされた型などは、type aliasだけで表現可能です。

typeだけで可能
type Status = 'pending' | 'active' | 'deleted';   // union
type Point = [number, number];                     // tuple
type Reducer<T> = (state: T, action: any) => T;    // 関数シグネチャに別名

interfaceはオブジェクト/関数の形にしか使えないため、こうした表現はできません。

オブジェクト型をもっと詳しく #

ここからは、typeまたはinterfaceで定義されたオブジェクト型のさまざまな表現を見ていきましょう。

オプショナルプロパティ #

オプショナル
type User = {
  id: string;
  name: string;
  age?: number;          // あってもなくてもよい
  email?: string;
};

const a: User = { id: 'u-1', name: '太郎' };                          // ✓
const b: User = { id: 'u-2', name: '花子', age: 28 };                  // ✓
const c: User = { id: 'u-3', name: '次郎', age: 35, email: 'm@x.com' };// ✓

readonly #

readonly
type User = {
  readonly id: string;
  name: string;
};

const u: User = { id: 'u-1', name: '太郎' };
u.name = '花子';     // ✓
u.id = 'u-2';        // 🚫

インデックスシグネチャ — 動的なキー #

キー名を事前に知らないときに使います。

動的なキー
type StringMap = {
  [key: string]: string;
};

const dict: StringMap = {
  apple: 'りんご',
  banana: 'バナナ',
  cherry: 'さくらんぼ',
};

dict['durian'] = 'ドリアン';  // 追加可能

{ [key: string]: T }は「文字列をキーに持ち、T型の値を持つオブジェクト」を意味します。たいていは既知のキー + 動的なキーを混ぜて使うときに便利です。

既知のキー + 動的なキー
type FormData = {
  name: string;
  email: string;
  [extra: string]: string;   // それ以外の任意のstringキーも許可
};

メソッドシグネチャ #

オブジェクトに関数が入っている形も表現できます。

メソッド
type Counter = {
  value: number;
  increment(): void;
  add(n: number): number;
};

const c: Counter = {
  value: 0,
  increment() { this.value += 1; },
  add(n) { return this.value + n; },
};

関数のシグネチャをプロパティとして書いた形も同じ意味です。

プロパティ形式
type Counter = {
  value: number;
  increment: () => void;
  add: (n: number) => number;
};

両方の表記はほぼ同じですが、微妙な違い(strictFunctionTypesオプション下で)があります。日常的には両方ともよく動作します。

関数型の別名 #

関数のシグネチャにも名前を付けられます。

関数型の別名
type Comparator = (a: number, b: number) => number;

const ascending: Comparator = (a, b) => a - b;
const descending: Comparator = (a, b) => b - a;

[3, 1, 4, 1, 5].sort(ascending);

Comparatorという名前で同じ関数の形を複数の箇所で再利用。コールバックがよく登場するライブラリAPIでよく見かけるパターンです。

型を合成する — 実戦 #

既存の型を結合して新しい型を作るパターンはよく登場します。

2つの型を合成 #

intersection
type Person = { name: string; age: number };
type Employee = { company: string; salary: number };

type EmployedPerson = Person & Employee;

const me: EmployedPerson = {
  name: '太郎',
  age: 30,
  company: 'Acme',
  salary: 50000,
};

A & Bは「Aのすべての属性とBのすべての属性をどちらも持つ型」です。

一部のフィールドだけ取り出す #

既存の型から一部を選んで使う
type User = { id: string; name: string; email: string; age: number };

type UserPreview = Pick<User, 'id' | 'name'>;
//   = { id: string; name: string }

type UserWithoutAge = Omit<User, 'age'>;
//   = { id: string; name: string; email: string }

PickOmitはTypeScriptに組み込まれたユーティリティ型です。実戦では本当によく使われます。#7でさらに詳しく扱います。

自分で試す — 小さな図書館システム #

ここまで学んだことを総合した小さな例です。

library.ts
type Book = {
  readonly id: string;
  title: string;
  author: string;
  publishedYear?: number;
  tags: string[];
};

type Member = {
  readonly id: string;
  name: string;
  email: string;
};

type Loan = {
  bookId: string;
  memberId: string;
  borrowedAt: Date;
  dueDate: Date;
  returnedAt?: Date;
};

type LoanWithDetails = Loan & {
  book: Book;
  member: Member;
};

function isOverdue(loan: Loan): boolean {
  if (loan.returnedAt) return false;
  return new Date() > loan.dueDate;
}

const book1: Book = {
  id: 'b-1',
  title: 'TypeScript入門',
  author: '著者',
  publishedYear: 2024,
  tags: ['プログラミング', '入門'],
};

const member1: Member = {
  id: 'm-1',
  name: '太郎',
  email: 'taro@example.com',
};

const loan1: Loan = {
  bookId: book1.id,
  memberId: member1.id,
  borrowedAt: new Date('2026-04-01'),
  dueDate: new Date('2026-04-15'),
};

console.log(isOverdue(loan1));  // 今日の日付基準

typeの定義が積み重なり、その上でisOverdueのような関数が安全に動作します。これがTypeScriptの真の価値です — データの形を先に明示し、それを基に関数やコンポーネントを作る流れです。

よくある落とし穴 #

1. interfaceの重複宣言が意図と違ってマージされる #

同じ名前のinterfaceを2か所で書くと自動でマージされます。それが意図したものなら良いのですが、たまたま別のモジュールで同じ名前を使ってしまうと、予期しないマージが起こりえます。大規模なコードベースではtypeのほうが安全な面があります。

2. オブジェクトリテラルのexcess property check #

excess property check
type User = { name: string };

const u: User = { name: '太郎', age: 30 };  // 🚫 ageはUserにない

const data = { name: '太郎', age: 30 };
const v: User = data;  // ✓ オブジェクト変数経由で代入するとOK (excess checkしない)

オブジェクトリテラルを直接代入するときだけ、追加プロパティのチェックが厳しくなります。変数を経由するともう少し緩くなります。最初は奇妙に見えますが、意図された動作です。

3. union vs intersection の混同 #

A | Bは「AまたはBのいずれか」(union、#4)、 A & Bは「AとBの合成」(intersection)です。

オブジェクトに対しては混乱しやすいのですが、intersectionは「両方の属性を持つオブジェクト」だと覚えておきましょう。unionは「2つのうち1つの形」です。

まとめ #

今回はオブジェクトと関数の型に名前を付ける2つの道具を扱いました。

  • type — どんな型にも別名が付けられる(オブジェクト、プリミティブ、union、tuple、関数…)
  • interface — オブジェクト/関数の形専用。宣言マージ可能
  • ほとんどの場合に互換性があり、チームの規約に沿って一貫して使う
  • オプショナル ?readonly、インデックスシグネチャのようなオブジェクト型の表現
  • & (intersection) で型を合成、Pick/Omit で一部を選んで使う

ここまでの型は1つの形でした。「これはUser」「これはBook」。しかし実際には「これはUserまたはGuest」「状態はloadingまたはsuccessまたはerror」のように、複数の可能性のうちの1つであるケースが多くあります。次回「TypeScript基礎講座 #4 Union / Literal / Narrowing」では、そういう場合を扱う強力な道具 — union型、literal型、narrowingを見ていきます。

X