ユニオン型とインターセクション型を分かりやすく解説

プロ太先生、TypeScriptって既存の型を組み合わせて新しい型を作れるって聞いたんですが、どういうことですか?

TypeScriptでは型を組み合わせて、より柔軟で実用的な型を作ることができるんだ。今日は『ユニオン型』と『インターセクション型』という2つの重要な型合成方法を教えよう。

1.ユニオン型(Union Types)とは?

まずはユニオン型から説明しよう。ユニオン型は『AまたはB』という意味で、複数の型のうちどれか一つを受け入れる型なんだ。

『または』ですか?具体例で教えてください!

例えば、ユーザーIDが数値でも文字列でも受け入れたい場合を考えてみよう。

// ユニオン型の基本例
let userId: number | string;  // number型 または string型を受け入れる

// 数値を代入
userId = 12345;
console.log(userId); // 12345

// 文字列を代入
userId = "user-abc-123";
console.log(userId); // "user-abc-123"

なるほど!|(パイプ記号)で区切って複数の型を指定するんですね。

1.1 リテラル型とユニオン型の組み合わせ

そうそう。特に便利なのが、リテラル型とユニオン型を組み合わせることなんだ。

// 動物の種類を限定する型
type AnimalType = 'dog' | 'cat' | 'rabbit' | 'hamster';

let myPet: AnimalType;

// 正しい値の代入
myPet = 'dog';     // OK
myPet = 'cat';     // OK

// 間違った値を代入しようとするとエラー
// myPet = 'elephant'; // エラー: Type '"elephant"' is not assignable to type 'AnimalType'

// 関数での活用例
function feedAnimal(animal: AnimalType): string {
    switch (animal) {
        case 'dog':
            return 'ドッグフードをあげました';
        case 'cat':
            return 'キャットフードをあげました';
        case 'rabbit':
            return 'にんじんをあげました';
        case 'hamster':
            return 'ひまわりの種をあげました';
        default:
            return '不明な動物です';
    }
}

console.log(feedAnimal('dog')); // 'ドッグフードをあげました'

これは便利ですね!決まった選択肢の中からしか選べないようにできるんですね。

1.2 オブジェクト型でのユニオン型

オブジェクト型でもユニオン型は使えるよ。
例えば、異なる形式のユーザー情報を扱う場合を見てみよう。

// 一般ユーザーの型
interface RegularUser {
    type: 'regular';        // ユーザータイプを識別するフィールド
    name: string;           // ユーザー名
    email: string;          // メールアドレス
}

// 管理者ユーザーの型
interface AdminUser {
    type: 'admin';          // ユーザータイプを識別するフィールド
    name: string;           // ユーザー名
    permissions: string[];  // 権限のリスト
}

// ゲストユーザーの型
interface GuestUser {
    type: 'guest';          // ユーザータイプを識別するフィールド
    sessionId: string;      // セッションID
}

// ユニオン型でまとめる
type User = RegularUser | AdminUser | GuestUser;

// 使用例
function greetUser(user: User): string {
    switch (user.type) {
        case 'regular':
            return `こんにちは、${user.name}さん!新着メールは${user.email}に送信されます。`;
        case 'admin':
            return `管理者の${user.name}さん、ようこそ!権限: ${user.permissions.join(', ')}`;
        case 'guest':
            return `ゲストユーザーさん、ようこそ!セッション: ${user.sessionId}`;
        default:
            return '不明なユーザーです';
    }
}

// テスト用のデータ
const regularUser: RegularUser = {
    type: 'regular',
    name: '田中太郎',
    email: 'tanaka@example.com'
};

const adminUser: AdminUser = {
    type: 'admin',
    name: '管理者',
    permissions: ['read', 'write', 'delete']
};

console.log(greetUser(regularUser)); // こんにちは、田中太郎さん!新着メールは...
console.log(greetUser(adminUser));   // 管理者の管理者さん、ようこそ!権限: ...

すごい!同じような構造だけど、少し違うオブジェクトをまとめて扱えるんですね。

2.インターセクション型(Intersection Types)とは?

次はインターセクション型だ。ユニオン型が『AまたはB』だったのに対して、インターセクション型は『AかつB』という意味なんだ。

『かつ』ですか?どういう時に使うんですか?

主にオブジェクト型を組み合わせて、より複雑な型を作る時に使うんだ。例を見てみよう。

// 基本情報の型
interface PersonalInfo {
    name: string;      // 名前
    age: number;       // 年齢
}

// 連絡先情報の型
interface ContactInfo {
    email: string;     // メールアドレス
    phone: string;     // 電話番号
}

// 住所情報の型
interface AddressInfo {
    address: string;   // 住所
    zipCode: string;   // 郵便番号
}

// インターセクション型で全ての情報を組み合わせ
type CompleteUserInfo = PersonalInfo & ContactInfo & AddressInfo;

// 使用例
const user: CompleteUserInfo = {
    // PersonalInfo のプロパティ
    name: '山田花子',
    age: 25,
    // ContactInfo のプロパティ
    email: 'yamada@example.com',
    phone: '090-1234-5678',
    // AddressInfo のプロパティ
    address: '東京都渋谷区1-2-3',
    zipCode: '150-0001'
};

// 全ての情報を表示する関数
function displayUserInfo(user: CompleteUserInfo): void {
    console.log(`名前: ${user.name}`);
    console.log(`年齢: ${user.age}歳`);
    console.log(`メール: ${user.email}`);
    console.log(`電話: ${user.phone}`);
    console.log(`住所: ${user.address} (〒${user.zipCode})`);
}

displayUserInfo(user);

なるほど!別々に定義した型を&で繋げて、全部の条件を満たす型を作れるんですね。

2.1 プロパティが重複した場合

いい理解だね。ただし、同じプロパティ名がある場合は少し複雑になるよ。

// 型Aの定義
interface TypeA {
    id: number;        // 数値のID
    name: string;      // 名前
    optional?: string; // 省略可能なプロパティ
}

// 型Bの定義
interface TypeB {
    id: string;        // 文字列のID(TypeAと型が異なる)
    email: string;     // メールアドレス
}

// 型Cの定義
interface TypeC {
    name: string;      // 名前(TypeAと同じ型)
    optional: string;  // 必須プロパティ(TypeAでは省略可能)
    phone: string;     // 電話番号
}

// TypeAとTypeBのインターセクション
type AandB = TypeA & TypeB;
// この場合、idプロパティは number & string となり、
// 実際には never型になる(数値かつ文字列は存在しないため)

// TypeAとTypeCのインターセクション
type AandC = TypeA & TypeC;
// この場合は以下のような型になる:
// {
//   id: number;
//   name: string;        // 同じ型なので問題なし
//   optional: string;    // 必須側が優先される
//   email: string;
//   phone: string;
// }

// 実際に使える例
const validUser: AandC = {
    id: 123,
    name: '佐藤次郎',
    optional: '追加情報',  // 必須になる
    phone: '080-9876-5432'
};

console.log(validUser);

同じプロパティ名でも型が違うとneverになっちゃうんですね。気をつけないと!

2.2 プリミティブ型でのインターセクション

そうなんだ。実際、プリミティブ型でインターセクションを試すとどうなるか見てみよう。

// プリミティブ型のインターセクション
type NumberAndString = number & string;  // これは never型になる
type StringAndBoolean = string & boolean; // これも never型になる

// never型の変数を作ってみる
let impossible: NumberAndString;

// どんな値も代入できない
// impossible = 123;      // エラー: Type 'number' is not assignable to type 'never'
// impossible = "hello";  // エラー: Type 'string' is not assignable to type 'never'
// impossible = true;     // エラー: Type 'boolean' is not assignable to type 'never'

console.log('プリミティブ型のインターセクションは実用的ではありません');

プリミティブ型では意味がないんですね。オブジェクト型で使うのが基本ということですか。

3.実践的な使い方の比較

最後に、インターフェースの継承とインターセクション型の違いも見てみよう。

// インターフェースの継承を使った場合
interface BaseUser {
    id: number;
    name: string;
}

interface ExtendedUser extends BaseUser {
    email: string;
    createdAt: Date;
}

// インターセクション型を使った場合
type BaseUserType = {
    id: number;
    name: string;
};

type ExtendedUserType = BaseUserType & {
    email: string;
    createdAt: Date;
};

// どちらも同じような結果になる
const user1: ExtendedUser = {
    id: 1,
    name: '鈴木一郎',
    email: 'suzuki@example.com',
    createdAt: new Date()
};

const user2: ExtendedUserType = {
    id: 2,
    name: '高橋二郎',
    email: 'takahashi@example.com',
    createdAt: new Date()
};

// 実用例:APIレスポンスの型を組み合わせる
type ApiResponse<T> = {
    success: boolean;
    message: string;
};

type UserData = {
    userId: number;
    username: string;
};

// ユニオン型とインターセクション型を組み合わせ
type UserApiResponse = ApiResponse<UserData> & {
    data: UserData | null;  // 成功時はUserData、失敗時はnull
};

const successResponse: UserApiResponse = {
    success: true,
    message: 'ユーザー情報を取得しました',
    data: {
        userId: 100,
        username: 'testuser'
    }
};

const errorResponse: UserApiResponse = {
    success: false,
    message: 'ユーザーが見つかりません',
    data: null
};

なるほど!継承でできることはインターセクション型でもできるんですね。

そうだね。ただし、エラーの出方に違いがある場合もあるから、プロジェクトの方針に合わせて選ぶといいよ。

まとめ

今日学んだことをまとめてみよう!

ユニオン型(A | B

  • 「AまたはB」の意味
  • 複数の型のうちどれか一つを受け入れる
  • リテラル型と組み合わせると列挙型のように使える
  • 異なる形式のデータを統一的に扱える

インターセクション型(A & B

  • 「AかつB」の意味
  • 複数の型を組み合わせて新しい型を作る
  • 主にオブジェクト型の合成に使用
  • 同じプロパティでは厳しい方の条件が適用される

TypeScriptの型システムって本当に柔軟なんですね!
これで更に安全なコードが書けそうです。

その通り!型を適切に組み合わせることで、コンパイル時により多くのエラーを捕捉できるようになるよ。実際のプロジェクトでどんどん使ってみてね!