TypeScript | 関数・クラス・ジェネリクス:ジェネリクス基礎 - ジェネリクス設計の判断基準

TypeScript TypeScript
スポンサーリンク

ゴール:「ここはジェネリクスにする/しない」を自分で決められるようになること

ジェネリクスを“書ける”ようになった次のステップは、
「どこで使うべきか」「どこでは使うべきでないか」を判断できることです。

これはもう、文法の話ではなく“設計のセンス”の話です。
でも、センスは分解するとルールになります。
ここでは、そのルールをできるだけ言語化していきます。

あなたがこれからコードを書くときに、
「ここはジェネリクスにする価値があるか?」を判断するための軸を、
具体例と一緒に整理していきます。

判断基準1:「呼び出し側が“型を差し替えたい”ニーズがあるか」

差し替えニーズがあるならジェネリクス候補

ジェネリクスの本質は一言でいうと「型の差し替え」です。
だから、まず最初の判断基準はこれです。

「この関数(クラス/interface)は、呼び出し側が“中身の型”を変えて使いたくなるか?」

例えば、配列を変換する関数。

function mapArray<Input, Output>(
  arr: Input[],
  fn: (value: Input) => Output
): Output[] {
  return arr.map(fn);
}
TypeScript

これは、呼び出し側が

数値配列を文字列配列に変えたい
ユーザー配列を ID 配列に変えたい

など、いろいろな型で使いたくなる関数です。
だからジェネリクスにする価値があります。

逆に、こういう関数はどうでしょう。

function toUpper(value: string): string {
  return value.toUpperCase();
}
TypeScript

これは「文字列を大文字にする」だけで、
呼び出し側が型を変えたい場面はありません。
ここを無理にジェネリクスにするのは過剰です。

function toUpperBad<T>(value: T): T {
  // @ts-ignore
  return value.toUpperCase();
}
TypeScript

判断のポイントは、「この型は、呼び出し側にとって“可変”である必要があるか?」です。
可変である必要があるならジェネリクス候補、
そうでないなら具体型で書いた方が素直です。

セキュリティ視点での差し替えニーズ

例えば、検証結果を表す型。

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

これは、メールアドレス、パスワード、URL など、
「いろいろな“安全な値”を包みたい」というニーズがあります。

type SafeEmail = { value: string };
type SafePassword = { value: string };

function validateEmail(input: string): ValidationResult<SafeEmail> { ... }
function validatePassword(input: string): ValidationResult<SafePassword> { ... }
TypeScript

ここでは、Value を差し替えられることに大きな意味があります。
「検証済みの安全な値」というパターンを、
いろいろなドメインに再利用できるからです。

こういう「安全なパターンをいろいろな型に適用したい」ときは、
ジェネリクスにする価値が高いです。

判断基準2:「共通パターンが“型として”見えているか」

ベタ書きが増えてきたら、パターンを探す

ジェネリクスは、最初から狙って書く必要はありません。
むしろ、最初はベタ書きでいいです。

例えば、こんなコードが増えてきたとします。

type GetUserResponse = {
  success: boolean;
  data: { id: number; name: string } | null;
  errorMessage?: string;
};

type GetProductResponse = {
  success: boolean;
  data: { id: number; title: string; price: number } | null;
  errorMessage?: string;
};
TypeScript

この時点ではジェネリクスは使っていません。
でも、ここで一歩引いて眺めると、

「success, data, errorMessage の“形”は同じで、
中身の data の型だけ違うな」

というパターンが見えてきます。

そこで、共通部分をジェネリクスとして抜き出します。

type ApiResponse<Data> = {
  success: boolean;
  data: Data | null;
  errorMessage?: string;
};

type GetUserResponse = ApiResponse<{ id: number; name: string }>;
type GetProductResponse = ApiResponse<{ id: number; title: string; price: number }>;
TypeScript

ここでの判断基準は、

「同じ“形”をした型が、型だけ違う状態で複数出てきているか?」

です。

形が同じ、中身だけ違う。
このとき初めて、「ジェネリクスにする意味」が生まれます。

形が違うのに無理にジェネリクスにしない

逆に、こういうのはジェネリクスにしない方がいい例です。

type User = { id: number; name: string };
type Product = { id: number; title: string; price: number };
TypeScript

どちらも id を持っているからといって、
無理に一つのジェネリクスにまとめる必要はありません。

// 無理な例
type Entity<T> = { id: number } & T;
TypeScript

こういう抽象化は、
「パターンが本当に“型として”共通しているか?」をよく見てからにした方がいいです。

共通パターンが見えていないのにジェネリクスにすると、
「何を表している型なのか」がかえって分かりにくくなります。

判断基準3:「ジェネリクスにすることで“型安全性”が上がるか」

any を減らせるなら、ジェネリクスの価値が高い

ジェネリクスの大きな役割の一つは、
「any を使わずに柔軟さを保つこと」です。

例えば、プロパティを安全に取得する関数。

悪い例はこうです。

function getPropBad(obj: any, key: string): any {
  return obj[key];
}
TypeScript

これは柔軟ですが、型安全性はゼロです。

ジェネリクスを使うと、こう書けます。

function getProp<T, K extends keyof T>(
  obj: T,
  key: K
): T[K] {
  return obj[key];
}
TypeScript

ここでは、

T がオブジェクト全体の型
K extends keyof T で「key は T のプロパティ名のどれか」
戻り値の型は T[K](そのプロパティの型)

という関係が、型で表現されています。

このように、

「ジェネリクスにすることで any を排除できる」
「ジェネリクスにすることで、型の関係性を正確に表現できる」

なら、ジェネリクスにする価値は高いです。

型安全性が変わらないなら、ジェネリクスは不要

逆に、こういうコード。

function sumBad<T extends number>(a: T, b: T): number {
  return a + b;
}
TypeScript

これは、ジェネリクスを使っても使わなくても、
型安全性は変わりません。

function sumGood(a: number, b: number): number {
  return a + b;
}
TypeScript

T を消しても安全性が変わらないなら、
ジェネリクスは“飾り”になってしまっています。

判断基準として、

「ジェネリクスにしたことで、何か一つでも“型で守れること”が増えたか?」

を自問してみてください。
増えていないなら、そのジェネリクスは要らない可能性が高いです。

判断基準4:「制約(extends)で“前提条件”を型に刻めるか」

コードが暗黙に持っている前提を、型に出せるなら価値がある

例えば、こういう関数。

function getLengthBad<T>(value: T): number {
  // @ts-ignore
  return value.length;
}
TypeScript

コードは「length を持つもの」を前提にしていますが、
型にはその前提が一切現れていません。

これをジェネリクス+制約で書き直すと、こうなります。

function getLength<Value extends { length: number }>(
  value: Value
): number {
  return value.length;
}
TypeScript

ここでは、

Value extends { length: number } が「前提条件」
getLength の中身が「その前提を使った処理」

という関係になっています。

このように、

「コードが暗黙に持っている前提条件を、extends で型に刻める」

なら、ジェネリクスにする価値があります。

セキュリティ的な前提条件を型にする

例えば、「id を持つものだけ扱う」関数。

function findByIdBad<T>(items: T[], id: number): T | undefined {
  // @ts-ignore
  return items.find((item) => item.id === id);
}
TypeScript

これも、T に制約がないのに id を前提にしています。

ジェネリクス+制約で書き直すと、こうなります。

interface HasId {
  id: number;
}

function findById<Entity extends HasId>(
  items: Entity[],
  id: number
): Entity | undefined {
  return items.find((item) => item.id === id);
}
TypeScript

ここでは、

「この関数は“id を持つエンティティ”だけを扱う」

というセキュリティ的にも重要な前提が、
型として明示されています。

判断基準として、

「この処理には“最低限こういう性質を持つ型しか渡してほしくない”という前提があるか?」

を考えてみてください。
あるなら、それはジェネリクス+制約の出番です。

判断基準5:「可読性と複雑さのバランスが取れているか」

ジェネリクスを入れたことで“読むのがつらくなっていないか”

どれだけ型が強くても、
誰も読めないコードは実務では負けです。

例えば、こういう型。

type Crazy<T, U, V> = Promise<Result<T & U, V | Error>>;
TypeScript

これをその場でベタっと使うと、
読む人は一瞬で思考停止します。

こういうときは、意味のある名前を付けて分解します。

type Result<Value, Err> =
  | { ok: true; value: Value }
  | { ok: false; error: Err };

type AsyncResult<Value, Err> = Promise<Result<Value, Err>>;
TypeScript

こうしておけば、

function fetchUser(id: number): AsyncResult<User, "NotFound"> { ... }
TypeScript

と書けて、
「ユーザーを取ってきて、成功なら User、失敗なら ‘NotFound’ を返す非同期処理」
という意図が一瞬で伝わります。

判断基準として、

「このジェネリクス、コメントなしで読んで意味が分かるか?」

を自分に問いかけてください。
分からないなら、名前・分解・型エイリアス化を検討する価値があります。

型パラメータの数も“最小限”を意識する

例えば、こういう関数。

function transformBad<T, U, V>(
  value: T,
  fn1: (v: T) => U,
  fn2: (v: U) => V
): V {
  return fn2(fn1(value));
}
TypeScript

動きは分かりますが、
T, U, V の関係を頭の中で追うのが少ししんどいです。

これを少し整理すると、こう書けます。

function pipe<Input, Middle, Output>(
  value: Input,
  fn1: (v: Input) => Middle,
  fn2: (v: Middle) => Output
): Output {
  return fn2(fn1(value));
}
TypeScript

型パラメータの数は同じでも、
名前を変えるだけで可読性が大きく変わります。

「型パラメータの数は本当にこれだけ必要か?」
「名前だけで関係性が伝わるか?」
この二つは、ジェネリクス設計の最後のチェックポイントです。

まとめ:ジェネリクス設計の判断基準を自分の言葉で言うと

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

ジェネリクスにするかどうかを決める判断基準は、おおざっぱに言うと次のようなもの。

呼び出し側が“中身の型”を差し替えたいニーズがあるか
同じ“形”で中身だけ違う型が複数出てきているか
ジェネリクスにすることで any を減らせるか、型安全性が上がるか
コードが暗黙に持っている前提条件を、extends で型に刻めるか
そのジェネリクスを入れても、シグネチャが“読める範囲”に収まっているか

今書いている、あるいはこれから書こうとしている関数や型を一つ選んで、
この五つを順番に当てはめてみてください。

一つでも「はい」と強く言えるなら、
そこはジェネリクスにする価値がある場所です。

逆に、どれにも自信を持って「はい」と言えないなら、
その場はまだ具体型で書いておいて、
後から“パターンが見えたタイミングで抽象化する”くらいがちょうどいいです。

ジェネリクスは「全部に付ける魔法」ではなく、
「ここぞというところにだけ効かせる設計の道具」です。
その“ここぞ”を見極める目が、今の話の積み重ねで少しずつ育っていきます。

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