TypeScript型定義の書き方|実務で使える基本パターン5選

TypeScriptの型定義は、コードの品質を大きく左右する重要なスキルです。この記事では、実務で頻繁に使用される型定義パターンを5つ紹介し、各パターンの使い分けと注意点を解説します。読了後は、型安全性の高いコードを迅速に書けるようになるでしょう。

TypeScript型定義が必要な理由

TypeScriptを採用する主な目的は、開発時にエラーを検出し、本番環境でのバグを減らすことです。適切な型定義がなければ、このメリットは失われます。特にチーム開発では、型定義が「コードの仕様書」として機能し、保守性が格段に向上します。

基本パターン1: インターフェース(interface)を使った構造定義

interfaceはオブジェクトの形状(構造)を定義する最も一般的な方法です。プロパティの型、オプション属性、メソッドシグネチャなどを明示的に宣言できます。

// ユーザー情報を定義するインターフェース
interface User {
  id: number;
  name: string;
  email: string;
  isActive?: boolean; // オプショナル属性
  createdAt: Date;
}

// 使用例
const user: User = {
  id: 1,
  name: "田中太郎",
  email: "tanaka@example.com",
  createdAt: new Date(),
  // isActiveは省略可能
};

// メソッドを含むインターフェース
interface UserRepository {
  findById(id: number): Promise<User | null>;
  save(user: User): Promise<void>;
}

interfaceのメリットと使うべき場面

interfaceは拡張(extends)や実装(implements)に優れており、大規模なアプリケーション設計に向いています。特にクラスベースのアーキテクチャやリポジトリパターンを採用する場合に活躍します。

基本パターン2: 型エイリアス(type)による柔軟な型定義

typeキーワードを使うと、プリミティブ型、ユニオン型、タプルなど、より広範な型を定義できます。interfaceより表現力が高いのが特徴です。

// ユニオン型:複数の型のいずれかを表現
type Status = "pending" | "completed" | "failed";

// リテラル型の組み合わせ
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";

// タプル型:固定長の配列
type Coordinate = [number, number];

// インターセクション型:複数の型を組み合わせる
type Admin = User & {
  role: "admin";
  permissions: string[];
};

// 使用例
const status: Status = "completed";
const method: HttpMethod = "GET";
const point: Coordinate = [10, 20];

const admin: Admin = {
  id: 1,
  name: "管理者",
  email: "admin@example.com",
  createdAt: new Date(),
  role: "admin",
  permissions: ["read", "write", "delete"],
};

typeとinterfaceの使い分け

interfaceはオブジェクト構造の契約に、typeは型の別名や複雑な型演算に向いています。チーム内ルールとしては「オブジェクト定義はinterface、それ以外はtype」というガイドラインが実務では一般的です。

基本パターン3: ジェネリクス(Generics)で再利用可能な型を作る

ジェネリクスを使うと、型をパラメータ化でき、同じ構造のコードを複数の型に対応させられます。これにより型の重複を減らし、保守性が向上します。

// ジェネリクスを使ったAPIレスポンス型
interface ApiResponse<T> {
  status: number;
  data: T;
  message: string;
}

// 異なる型に対応させる
type UserResponse = ApiResponse<User>;
type UserListResponse = ApiResponse<User[]>;

// ジェネリクス制約:型パラメータに条件を付ける
interface Repository<T extends { id: number }> {
  findById(id: number): Promise<T | null>;
  getAll(): Promise<T[]>;
}

// 実装例
class UserRepository implements Repository<User> {
  async findById(id: number): Promise<User | null> {
    // 実装...
    return null;
  }

  async getAll(): Promise<User[]> {
    return [];
  }
}

// 関数でもジェネリクスを使用
function wrapInResponse<T>(data: T, message: string): ApiResponse<T> {
  return {
    status: 200,
    data,
    message,
  };
}

// 使用例
const response = wrapInResponse<User>(user, "ユーザー取得成功");

ジェネリクスのハマりポイント

ジェネリクスを過度に使うとコードが複雑になります。「3つ以上の型パラメータが必要になった」場合は、設計を見直すサインです。また、TypeScriptは型推論が優秀なため、明示的に型を指定しない(<T>を省略)ことで可読性が上がる場合も多くあります。

基本パターン4: ユーティリティ型で既存型を変換

TypeScriptの組み込みユーティリティ型(PartialRequiredPickなど)を活用すると、既存の型定義から新しい型を効率的に生成できます。

// 元の型
interface User {
  id: number;
  name: string;
  email: string;
  age: number;
}

// Partial:すべてのプロパティをオプショナルに
type PartialUser = Partial<User>;
// 同等: { id?: number; name?: string; email?: string; age?: number; }

// Required:すべてのプロパティを必須に
type RequiredUser = Required<User>;

// Pick:指定したプロパティのみを抽出
type UserPreview = Pick<User, "id" | "name">;
// 同等: { id: number; name: string; }

// Omit:指定したプロパティを除外
type UserWithoutEmail = Omit<User, "email">;
// 同等: { id: number; name: string; age: number; }

// Record:キーと値の型を指定
type UserRole = Record<"admin" | "user" | "guest", string>;
// 同等: { admin: string; user: string; guest: string; }

// 実装例
const updateUserDto: PartialUser = {
  name: "新しい名前",
  // 他のフィールドは省略可能
};

const roles: UserRole = {
  admin: "管理者",
  user: "ユーザー",
  guest: "ゲスト",
};

ユーティリティ型を使うべき場面

ユーティリティ型は、同一の構造から複数のバリエーション型を作成する必要がある場合に非常に有効です。例えば、APIリクエストボディ(部分更新)とレスポンスボディ(完全なデータ)で異なる型が必要な場合、PartialRequiredの組み合わせで型の重複を排除できます。

基本パターン5: 複雑なオブジェクト型の実践的な組み合わせ

実務では、複数のパターンを組み合わせて複雑なドメインモデルを表現することがほとんどです。以下は、Eコマースアプリケーションの注文管理を題材にした実践例です。

// ベース型の定義
interface Product {
  id: string;
  name: string;
  price: number;
  inventory: number;
}

interface OrderItem {
  product: Product;
  quantity: number;
  unitPrice: number;
}

// ユニオン型で注文ステータスを表現
type OrderStatus = "pending" | "processing" | "shipped" | "delivered" | "cancelled";

// ジェネリクスと制約を組み合わせた型
interface Paginated<T> {
  items: T[];
  total: number;
  page: number;
  perPage: number;
}

// インターセクションで複数の型を合成
type Order = {
  id: string;
  items: OrderItem[];
  status: OrderStatus;
  totalPrice: number;
  createdAt: Date;
  updatedAt: Date;
} & (
  | { status: "pending"; paymentMethod: string }
  | { status: "processing"; processedAt: Date }
  | { status: "shipped" | "delivered"; trackingNumber: string }
  | { status: "cancelled"; cancellationReason: string }
);

// ユーティリティ型で派生型を作成
type OrderUpdateRequest = Partial<Omit<Order, "id" | "createdAt">>;
type OrderListResponse = Paginated<Pick<Order, "id" | "status" | "totalPrice" | "createdAt">>;

// 関数の型シグネチャ
async function fetchOrders(page: number = 1): Promise<OrderListResponse> {
  // 実装...
  return {
    items: [],
    total: 0,
    page,
    perPage: 10,
  };
}

async function updateOrder(id: string, data: OrderUpdateRequest): Promise<Order> {
  // 実装...
  throw new Error("Not implemented");
}

複雑な型定義のベストプラクティス

複雑になった型定義は、ファイルを分割して整理することをお勧めします。例えば、types/order.tstypes/product.tsのように関心ごとで分けることで、可読性と保守性が向上します。また、型の責任が不明確になったら、それは設計に問題がある可能性があります。

実装時のハマりポイントと解決策

「Excess Property Checks」エラー

TypeScriptは、オブジェクトリテラルに型で定義されていないプロパティがあると警告します。これは設計の安全性を高めるためですが、時には柔軟に対応する必要があります。

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

// ❌ エラー:type has no property 'age'
const user: User = {
  name: "太郎",
  email: "taro@example.com",
  age: 30, // 型定義にない
};

// ✅ 解決策1:インデックスシグネチャを追加
interface User {
  name: string;
  email: string;
  [key: string]: string | number; // 任意のキーを許可
}

// ✅ 解決策2:型アサーション(キャスト)を使う
const user: User = {
  name: "太郎",
  email: "taro@example.com",
  age: 30,
} as User;

// ✅ 解決策3:一度変数に代入してから型をつける
const userData = {
  name: "太郎",
  email: "taro@example.com",
  age: 30,
};
const user: User = userData;

型推論の失敗

配列や関数の戻り値で型推論に失敗し、any型に落ち込むことがあります。これを防ぐには明示的に型を指定することが重要です。

// ❌ 型推論が失敗(items は any[])
const items = [];
items.push({ id: 1, name: "商品1" });

// ✅ 明示的に型を指定
const items: Array<{ id: number; name: string }> = [];
items.push({ id: 1, name: "商品1" });

// ✅ またはインターフェースを活用
interface Product {
  id: number;
  name: string;
}

const items: Product[] = [];
items.push({ id: 1, name: "商品1" });

TypeScript 5.0以降の新機能

TypeScript 5.0ではconst型パラメータが導入され、より精密な型推論が可能になりました。以下は最新の書き方の一例です。

// TypeScript 5.0+:const型パラメータ
function createArray<const T>(item: T) {
  return [item];
}

// 戻り値の型が正確に推論される
const result = createArray("hello");
// resultの型: ["hello"] ではなく string[]
    
K
AWS・Python・生成AIを専門とするソフトウェアエンジニア。AI・クラウド・開発ワークフローの実践ガイドを執筆しています。詳しく見る →