ゴール:「“ありがちな事故パターン”を先に知っておいて、自分のジェネリクスを危険運転にしない」
ジェネリクスは強力だけど、そのぶん「やらかしポイント」も多いです。
しかも、やらかした瞬間はコンパイルが通るので、気づくのはだいたい本番かテストです。
ここでは、実務で本当にありがちな失敗パターンを
「なぜそれが危ないのか」「どう直すといいのか」まで含めて整理します。
あなたがこれから書くジェネリクスが、
同じ穴に落ちないようにするための“地雷マップ”だと思って読んでください。
失敗例1:T を使っているつもりで、実は any と変わらない
典型パターン:T を受け取っているのに、構造を無視して扱う
まず一番多いのがこれです。
function logAndModifyBad<T>(value: T): T {
console.log(value);
// @ts-ignore
value.modified = true;
return value;
}
TypeScript一見「ジェネリクスを使っていて型安全そう」に見えますが、
やっていることは 「T の中身を何も知らないのに、勝手にプロパティを足している」 です。
これは、型の世界ではほぼ any と同じです。
T を使っている意味がありません。
本来、ジェネリクスは
「T が何であっても、その T のルールを守る」
ための仕組みです。
T に存在しないプロパティを勝手に触り始めた瞬間、
ジェネリクスの価値は崩壊します。
直すなら、最低でも制約と戻り値の型をきちんと書きます。
function addModifiedFlag<T extends object>(
value: T
): T & { modified: true } {
return { ...value, modified: true };
}
TypeScriptここでは、
T extends object で「オブジェクトであること」を保証し、
戻り値の型も T & { modified: true } と明示しています。
重要なのは、「T に対して何をするのか」を型で表現することです。
それをサボると、ジェネリクスは一瞬で“型安全っぽい any”になります。
セキュリティ的に見ると何がまずいか
「型を無視して勝手にプロパティを足す」というのは、
セキュリティで言えば 「想定外のフィールドを勝手に混入させる」 行為です。
ログやトークン、ユーザー情報などに
意図しないフィールドが紛れ込むと、
情報漏えいや権限チェックの抜け道になりかねません。
ジェネリクスを使うときは、
「T の外側に何かを足すなら intersection で明示する」
「T の中身を前提にするなら extends で制約を書く」
このどちらかを必ずセットにしてください。
失敗例2:過剰ジェネリクスで、かえって読みづらく壊れやすい
典型パターン:本当は固定でいいのに、何でも 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 を何でも T にしてしまう
function getUserByIdBad<T>(id: T) {
// 実際には number 前提で DB を叩いている…
}
TypeScriptプロジェクト全体で「ユーザー ID は number」と決めているなら、
これは完全に過剰ジェネリクスです。
function getUserByIdGood(id: number) {
// number 前提で OK
}
TypeScript「将来 string になるかもしれないから T にしておこう」は、
だいたいの場合「将来の自分を混乱させるだけ」です。
ジェネリクスを使う前に、一度こう自問してください。
「この型、呼び出し側が本当に変えたい場面があるか?」
ないなら、ジェネリクスは要りません。
失敗例3:型パラメータが増えすぎて、誰も読めない
典型パターン:T, U, V, W… と増やしすぎる
例えば、こんな関数。
function combineBad<T, U, V, W>(
a: T,
b: U,
c: V,
d: W
): T & U & V & W {
return { ...a, ...b, ...c, ...d };
}
TypeScript動きは分かりますが、
読む側は「T と U と V と W の関係」を追いかけるだけで疲れます。
多くの場合、ここまで汎用にする必要はありません。
2つに絞るだけで、だいぶ読みやすくなります。
function mergeTwo<A, B>(a: A, b: B): A & B {
return { ...a, ...b };
}
TypeScript3つ以上マージしたいなら、呼び出し側でネストすればいいだけです。
const merged = mergeTwo(mergeTwo(a, b), c);
TypeScript型パラメータが増えるほど、
「この関数は何をしたいのか」がぼやけていきます。
ジェネリクスを設計するときは、
「本当に必要な可変部分だけを型パラメータにする」
という意識を強く持ってください。
セキュリティ的な観点
型パラメータが増えすぎると、
「どの型がどの責任を持っているのか」が分かりにくくなります。
責任の境界が曖昧になると、
権限チェックや入力検証の抜け漏れが起きやすくなります。
「このジェネリクスは、何を表しているのか?」
「それぞれの型パラメータは、どんな役割を持っているのか?」
を説明できないジェネリクスは、
一度立ち止まって削ることを検討した方がいいです。
失敗例4:extends を付け忘れて、前提条件が型に現れていない
典型パターン:length を使っているのに、制約がない
function getLengthBad<T>(value: T): number {
// @ts-ignore
return value.length;
}
TypeScriptこの関数は「length を持つもの」を前提にしているのに、
T に何の制約も付けていません。
その結果、呼び出し側からは
「T なら何でも渡せる関数」
に見えます。
正しくはこうです。
function getLengthGood<Value extends { length: number }>(
value: Value
): number {
return value.length;
}
TypeScriptValue extends { length: number } と書くことで、
「この関数は length を持つものだけを受け取る」
という前提条件が、型にきちんと現れます。
制約を付け忘れると、
「コードは前提条件を持っているのに、型はそれを表していない」
というズレが生まれます。
これは、バグの温床です。
セキュリティ的な前提条件も必ず型にする
例えば、「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 findByIdGood<Entity extends HasId>(
items: Entity[],
id: number
): Entity | undefined {
return items.find((item) => item.id === id);
}
TypeScriptセキュリティ的に重要な前提(id を持つ、token を持つ、権限を持つなど)は、
必ず extends で型に刻むようにしてください。
失敗例5:推論で十分なのに、毎回 <T> を書いてノイズになる
典型パターン:identity<number>(1) と全部に書きたくなる
function identity<T>(value: T): T {
return value;
}
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> を書くと、
「型パラメータの情報量に対して、視覚的なノイズが増えすぎる」ことがあります。
明示した方がいいのは、
「引数から 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「ここは推論で十分か?」「ここは明示しないと意図が伝わらないか?」
この見極めが、可読性の高いジェネリクスと
“記号だらけで怖いジェネリクス”の分かれ目です。
失敗例6:ジェネリクスを使っているのに、結局 any に逃げる
典型パターン:途中で any にキャストしてしまう
function getPropBad<T>(obj: T, key: string): any {
return (obj as any)[key];
}
TypeScriptこれも実務でよく見ます。
「ジェネリクスで型安全にしたいけど、面倒だから any に逃げる」パターンです。
これでは、ジェネリクスを使っている意味がほぼありません。
本当にやりたいことは、
「T のプロパティを安全に取る」ことのはずです。
function getPropGood<T, K extends keyof T>(
obj: T,
key: K
): T[K] {
return obj[key];
}
TypeScriptここでは、
K extends keyof T で「key は T のプロパティ名のどれか」
戻り値の型は T[K](そのプロパティの型)
と、型で関係性をきちんと表現しています。
ジェネリクスを使うなら、
「any に逃げた瞬間に負け」くらいの気持ちでいていいです。
セキュリティ的な危険
any に逃げると、
「本来ありえない値」が紛れ込んでもコンパイルが通ります。
入力検証・権限チェック・ログのフォーマットなど、
セキュリティに関わる部分で any を混ぜると、
「危険な値が安全なふりをして通過する」ことになります。
ジェネリクスは、
「any を使わずに柔軟さと安全性を両立するための道具」です。
そこから any に戻るのは、完全に逆走です。
まとめ:実務でのジェネリクスの失敗を、自分のチェックリストに変える
最後に、あなた自身の言葉でこう整理してみてください。
実務でよくあるジェネリクスの失敗は、
T を使っているつもりで、実は any と変わらない
本当は固定でいいのに、何でも T にしてしまう
型パラメータを増やしすぎて、誰も読めない
extends を付け忘れて、前提条件が型に現れていない
推論で十分なのに、毎回 <T> を書いてノイズになっている
途中で any に逃げて、ジェネリクスの意味を潰している
このあたりに集中している。
だから、自分のジェネリクスを見直すときは、
次のように自問してみるといいです。
「この T、本当に呼び出し側が変えたい型か?」
「この関数の前提条件は、extends でちゃんと型に出ているか?」
「any に逃げていないか?」
「型パラメータの数と名前は、読んだ瞬間に関係が分かるか?」
この問いを一度でも通したジェネリクスは、
もう“なんとなく書いた T”ではなく、
実務で戦える“ちゃんと設計された型”になっていきます。
