TypeScript | 関数・クラス・ジェネリクス:ジェネリクス基礎 - 過剰ジェネリクスの回避

TypeScript TypeScript
スポンサーリンク

ゴール:「ジェネリクスは“カッコいい飾り”じゃなくて、“必要なときだけ出す道具”だと理解する

ジェネリクスに慣れてくると、ほぼ確実に一度は通る道があります。
それが 「何でもかんでも <T> を付けたくなる病」 です。

でも、ジェネリクスは“書けば書くほど良い”ものではありません。
むしろ、「必要なところにだけ、最小限」 が正解です。

ここでは、過剰ジェネリクスの典型パターンと、
「ここはジェネリクスいらないよね」を見抜く感覚を、例と一緒に整理していきます。

そもそも「過剰ジェネリクス」とは何か

定義を一言でいうと

一言でいうと、

「ジェネリクスを使っているけれど、
実は普通の型(string, number, 具体的な型)で十分な場面」

です。

もう少し分解すると、こんな状態が怪しいです。

型パラメータを増やしているのに、実質 1 種類の型しか使っていない
T にしているけど、呼び出し側が T を変える必要がない
ジェネリクスにしたせいで、コードが読みにくくなっている

つまり、「柔軟性を増やしたつもりが、読みやすさと安全性を落としている」状態です。

典型パターン1:実質固定なのに T にしてしまう

悪い例:何でも T にしてしまう関数

例えば、こういうコード。

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

一見ジェネリックでカッコよく見えますが、これは完全にアウトです。

この関数は「文字列を大文字にしたい」だけなのに、
T にしてしまったせいで、呼び出し側からはこう見えます。

「T なら何でも渡せるし、T がそのまま返ってくる関数」

でも実際には、string 以外を渡すと壊れます。

本当にやりたいことはこれです。

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

ここでの重要ポイントは、

「呼び出し側が型を変えられる必要がないなら、ジェネリクスにしない」

ということです。

ジェネリクスは「呼び出し側が型を差し替えられる」ための仕組みです。
差し替える必要がないなら、具体的な型で十分です。

もう少し現実的な例

例えば、ユーザー ID は必ず number だと決めているプロジェクトで、
こんな関数を書いたとします。

function getUserByIdBad<T>(id: T) {
  // 実際には number 前提で DB を叩いている…
}
TypeScript

これも過剰ジェネリクスです。
この関数は「number の ID でユーザーを取ってくる」だけなので、
素直にこう書くべきです。

function getUserByIdGood(id: number) {
  // number 前提で OK
}
TypeScript

「将来 string になるかもしれないから T にしておこう」は、
だいたいの場合「将来の自分を混乱させるだけ」です。

典型パターン2:型パラメータが多すぎる

悪い例:意味のない T, U, V の乱立

例えば、こんなコード。

function mergeBad<T, U, V>(a: T, b: U, c: V): T & U & V {
  return { ...a, ...b, ...c };
}
TypeScript

一見「汎用的で強そう」に見えますが、
実際にはこうで十分です。

function mergeBetter<T, U>(a: T, b: U): T & U {
  return { ...a, ...b };
}
TypeScript

3 つ以上マージしたいなら、呼び出し側でネストすればいいだけです。

const merged = mergeBetter(mergeBetter(a, b), c);
TypeScript

型パラメータが増えるほど、
読む側は「T と U と V の関係」を追いかけるコストが増えます。

重要なのは、

「本当に必要な可変部分だけを型パラメータにする」

ということです。

「とりあえず T, U, V を増やしておく」は、
過剰ジェネリクスの典型パターンです。

典型パターン3:ジェネリクスでやっていることが any と変わらない

悪い例:T を使っているようで、実は守れていない

function logAndReturnBad<T>(value: T): T {
  console.log(value);
  // @ts-ignore
  value.extra = 123;
  return value;
}
TypeScript

T を使っているように見えますが、
実際には value.extra を勝手に追加していて、
T の構造を完全に無視しています。

これは、型安全性の観点では any とほぼ変わりません。

本来ジェネリクスは、

「T が何であっても、その T のルールを守る」

ための仕組みです。

T を無視してプロパティを足したり、
T に存在しないメソッドを呼んだりしている時点で、
ジェネリクスを使う意味がなくなっています。

良い形に直すなら

例えば、「ログを出して、そのまま返す」だけならこうで十分です。

function logAndReturnGood<T>(value: T): T {
  console.log(value);
  return value;
}
TypeScript

「extra を足したい」なら、
T に制約を付けるか、intersection を使うべきです。

function addExtra<T extends object>(value: T): T & { extra: number } {
  return { ...value, extra: 123 };
}
TypeScript

ここでは、

T extends object で「オブジェクトであること」を保証し、
戻り値の型も T & { extra: number } と明示しています。

ジェネリクスを使うなら、
「T のルールを守る」「T に対して何をするかを型で表現する」
この 2 つは絶対に外さない方がいいです。

典型パターン4:推論で十分なのに、毎回 <T> を書いてしまう

毎回 <number> と書きたくなる病

例えば、こんな関数があるとします。

function identity<T>(value: T): T {
  return value;
}
TypeScript

これをこう呼ぶ人がいます。

const a = identity<number>(1);
const b = identity<string>("hello");
TypeScript

もちろん間違いではありません。
でも、TypeScript の型推論があるので、
普通はこう書けば十分です。

const a = identity(1);       // T = number
const b = identity("hello"); // T = string
TypeScript

「毎回 <T> を書く」のは、
ジェネリクスに慣れてきた人ほどやりがちな“過剰さ”です。

重要なのは、

「型推論で十分に安全なら、あえて明示しない」

という姿勢です。

明示するのは、
推論では意図した型にならないときだけでいいです。

明示した方がいいケースとの違い

例えば、これは明示した方がいいケースです。

function parseJson<T>(text: string): T {
  return JSON.parse(text) as T;
}

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

const user = parseJson<User>('{"id":1,"name":"Taro"}');
TypeScript

ここでは、引数から T を推論できないので、
<User> を書く意味があります。

逆に、引数から T が素直に決まる関数に対して、
毎回 <T> を書くのは「ノイズを増やしているだけ」になりがちです。

過剰ジェネリクスを避けるためのチェック質問

質問1:「この T は、呼び出し側が“変えたい”場面が本当にあるか?」

もし答えが「ない」なら、
その T は具体的な型にしてしまっていい可能性が高いです。

例えば、

常に string を扱う関数
常に User を扱うリポジトリ
常に number の ID を扱う関数

などは、無理にジェネリクスにしなくて構いません。

質問2:「T を消しても、型安全性は保たれるか?」

例えば、こういう関数。

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

これ、T いりますか?
正直、こうで十分です。

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

T を消しても型安全性が変わらないなら、
そのジェネリクスは過剰である可能性が高いです。

質問3:「このジェネリクスは、プロジェクト内で“パターン”として再利用されているか?」

例えば、

ApiResponse<T>
ValidationResult<T>
Repository<T>

のように、あちこちで使われているなら、
それは良いジェネリクスです。

逆に、

そのファイルの中の 1 箇所でしか使っていない
しかも T を変えて使っていない

なら、ジェネリクスをやめて具体型にした方が読みやすくなります。

まとめ:「過剰ジェネリクスの回避」を自分の言葉で説明すると

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

ジェネリクスは、

「呼び出し側が型を差し替えられるようにするための“型の穴”」

であって、

「何となくカッコよくするための飾り」ではない。

だから、

実質 1 種類の型しか使わないなら、具体型で書く
T を消しても型安全性が変わらないなら、ジェネリクスをやめる
推論で十分なところに <T> を書きすぎない
T のルールを無視して any 的に扱わない

このあたりを意識すると、「ちょうどいいジェネリクス」になっていきます。

もし今、自分のコードにジェネリクスがいくつかあるなら、
一つ選んで、こう自問してみてください。

「この <T>、本当に“呼び出し側が変えたい型”になっているか?
T を消しても困らないなら、それは過剰ジェネリクスかもしれない。」

その問いを一度でも通したジェネリクスは、
もう“なんとなく書いた T”ではなく、
ちゃんと意味を持った設計になっていきます。

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