TypeScript | 関数・クラス・ジェネリクス:ジェネリクス基礎 - ジェネリクスとunionの併用

TypeScript TypeScript
スポンサーリンク

ゴール:「T なのに T | U もアリ?を“安全に混ぜる感覚”で理解する

ジェネリクスと union(|)を一緒に使うとき、
最初に出てくるモヤモヤはだいたいこれです。

「T って“1つの型”じゃないの?
T | U みたいに“複数の型”とどう共存するの?」

ここでのゴールは、

「ジェネリクスは“型の穴”、union は“型の候補の集合”
それをどう組み合わせると“柔軟だけど安全な API”になるか

を、コードレベルで腑に落とすことです。

基本イメージ:T は「穴」、union は「候補のセット」

まずは union 単体のイメージを整理する

union 型は、こういうやつです。

type Id = number | string;

let id: Id;
id = 1;
id = "user-1";
// id = true; // エラー
TypeScript

number | string は、

「number か string のどちらか」

という“候補の集合”です。

この時点では、まだジェネリクスは出てきません。
ただ、

「1つの変数に、複数の型の可能性を持たせる」

という感覚だけ持っておいてください。

ジェネリクスと union を組み合わせるとどうなるか

例えば、こういうジェネリック型を考えます。

type ApiResult<T> = {
  success: true;
  data: T;
} | {
  success: false;
  error: string;
};
TypeScript

ここでは、

  • 外側に union(成功パターン or 失敗パターン)
  • 内側にジェネリクス(成功時の data の型が T)

という構造になっています。

使うときはこうです。

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

const ok: ApiResult<User> = {
  success: true,
  data: { id: 1, name: "Taro" },
};

const ng: ApiResult<User> = {
  success: false,
  error: "Unauthorized",
};
TypeScript

ここでのポイントは、

「ジェネリクス(T)は“成功時の data の型”という穴」
「union は“成功 or 失敗”というパターンの集合」

として役割分担していることです。

ジェネリクスと union は、
「どっちか一方だけ使うもの」ではなく、
“役割の違うレイヤー”として重ねて使うイメージです。

パターン1:T 自体を union にする

「T は number か string のどちらか」というジェネリクス

ジェネリクスの型パラメータ自体を union にすることもできます。

function toArray<T>(value: T): T[] {
  return [value];
}

const a = toArray<number | string>(1);   // (number | string)[]
a.push("hello");
TypeScript

ここでは、

  • T = number | string
  • 戻り値の型は (number | string)[]

という関係です。

重要なのは、

「T は“1つの型”というより、“1つの型表現”」

だということです。

その“1つの型表現”が、
number でもいいし、number | string でもいい。

ジェネリクスは「型の穴」であって、
「必ず単一のプリミティブ型でなければならない」わけではありません。

セキュリティ視点での意味

T = number | string のように union を使うと、

「この配列には number と string 以外は入らない」

という制約が型レベルでかかります。

any[] だと「何でも入る」状態ですが、
(number | string)[] だと「許可された型だけが入る」状態です。

これは、セキュリティでいう ホワイトリスト に近い考え方です。

パターン2:ジェネリクスの中で union を返す

「成功時は T、失敗時はエラー」の関数

例えば、何かを検証する関数を考えます。

type ValidationResult<T> =
  | { ok: true; value: T }
  | { ok: false; error: string };

function validateNumber(input: string): ValidationResult<number> {
  const n = Number(input);
  if (Number.isNaN(n)) {
    return { ok: false, error: "Not a number" };
  }
  return { ok: true, value: n };
}
TypeScript

ここでは、

  • ValidationResult<T> が「T かエラー」の union
  • validateNumberValidationResult<number> を返す

という構造です。

さらに汎用化して、ジェネリクス関数にしてみます。

function validate<T>(
  input: string,
  parser: (text: string) => T | null
): ValidationResult<T> {
  const result = parser(input);
  if (result === null) {
    return { ok: false, error: "Invalid input" };
  }
  return { ok: true, value: result };
}
TypeScript

使うときはこうです。

const r1 = validate<number>("123", (text) => {
  const n = Number(text);
  return Number.isNaN(n) ? null : n;
});

const r2 = validate<Date>("2024-01-01", (text) => {
  const d = new Date(text);
  return Number.isNaN(d.getTime()) ? null : d;
});
TypeScript

ここでのポイントは、

「ジェネリクス(T)は“成功時の値の型”」
「union は“成功 or 失敗”という結果のバリエーション」

という役割分担です。

セキュリティ的に言えば、

  • 入力をパースする
  • 成功したら“安全な型 T”として扱う
  • 失敗したらエラーとして扱う

という“安全な入力処理”の型表現になっています。

パターン3:union の中でジェネリクスを使う(判別可能 union)

判別プロパティ+ジェネリクス

セキュリティ的なイベントログを考えてみます。

type LoginEvent<TUserId> =
  | { type: "success"; userId: TUserId; ip: string }
  | { type: "failure"; reason: string; ip: string };
TypeScript

ここでは、

  • type"success" or "failure" の判別プロパティ
  • userId の型だけジェネリクス(TUserId)

という構造です。

使うときはこうです。

type NumericLoginEvent = LoginEvent<number>;
type StringLoginEvent = LoginEvent<string>;
TypeScript

処理側はこう書けます。

function handleLoginEvent<TUserId>(event: LoginEvent<TUserId>) {
  if (event.type === "success") {
    // event.userId: TUserId
    // event.ip: string
  } else {
    // event.reason: string
    // event.ip: string
  }
}
TypeScript

ここでのポイントは、

「union の“形”は固定しつつ、一部のフィールドの型だけジェネリクスで差し替える」

という設計です。

これにより、

  • イベントの構造(type, ip, reason など)は統一
  • userId の表現(number, string, UUID など)はシステムごとに変えられる

という柔軟さと一貫性を両立できます。

パターン4:ジェネリクス+union+制約(extends)

「T はこの union のどれかに限る」

ジェネリクスに union を使った制約もよく出てきます。

type Role = "admin" | "user" | "guest";

function requireRole<T extends Role>(role: T) {
  // ...
}

requireRole("admin"); // OK
requireRole("user");  // OK
// requireRole("root"); // エラー
TypeScript

ここでは、

  • Role が union(許可されたロールの集合)
  • T extends Role が「T はそのどれかに限る」という制約

です。

さらに、戻り値にジェネリクスを絡めることもできます。

function canAccessAdmin<T extends Role>(role: T): boolean {
  return role === "admin";
}
TypeScript

セキュリティ的には、

「許可された値の集合(union)を定義し、
ジェネリクス+制約で“その中のどれか”に限定する」

という、かなり堅い設計になります。

ありがちなつまずきポイントと考え方

「T | U」と「T が union」の違いがごちゃごちゃになる

例えば、次の 2 つは別物です。

// 1: 戻り値が「T か U」
function f1<T, U>(value: T): T | U {
  // ...
  throw new Error();
}

// 2: T 自体が「A か B」
function f2<T>(value: T): T {
  return value;
}

const x = f2<"A" | "B">("A"); // T = "A" | "B"
TypeScript

整理すると、

  • T | U は「2つの型パラメータの union」
  • T = A | B は「1つの型パラメータに union 型を入れている」

という違いです。

どちらも「union とジェネリクスの併用」ですが、
「何を可変にしたいのか(型パラメータの数/union の中身)」を意識すると整理しやすくなります。

「union だから何でもできる」は間違い

number | string だからといって、
number のメソッドも string のメソッドも自由に呼べるわけではありません。

function logId(id: number | string) {
  // id.toFixed(2);   // エラー
  // id.toUpperCase(); // エラー

  console.log(String(id)); // OK
}
TypeScript

union 型に対してできることは、

  • 両方に共通する操作
  • 型ガードで絞り込んだあとの操作

だけです。

ジェネリクスと union を組み合わせるときも同じで、

「T が union だからといって、T の中の全パターンのメソッドを無条件に呼べるわけではない」

という制約は常に意識しておく必要があります。

まとめ:ジェネリクスと union の併用を自分の言葉で説明すると

最後に、あなた自身の言葉でこう整理してみてください。

ジェネリクスは「型の穴」、
union は「型の候補の集合」。

これを組み合わせると、

  • 「成功時は T、失敗時はエラー」のような安全な結果型
  • 「イベントの形は同じだが、一部のフィールドの型だけ差し替え可能」な設計
  • 「許可された値の集合(union)からしか選べない」制約付きジェネリクス

などを表現できる。

まずは次の 3 つを、自分の手で書いてみてください。

type ApiResult<T> = { success: true; data: T } | { success: false; error: string };

function toArray<T>(value: T): T[] { return [value]; }

type LoginEvent<TUserId> =
  | { type: "success"; userId: TUserId; ip: string }
  | { type: "failure"; reason: string; ip: string };
TypeScript

そして、T に単一型と union 型の両方を入れてみて、
型推論の結果をエディタで眺めてみてください。

そこで、

「ジェネリクスは“穴”、union は“候補のセット”。
それを重ねると、柔軟だけど境界がちゃんと守られた設計になる。」

と感じられたら、
ジェネリクスと union の併用はもうしっかり掴めています。

タイトルとURLをコピーしました