TypeScript | 関数・クラス・ジェネリクス:ジェネリクス基礎 - 実務でよくある失敗例

TypeScript TypeScript
スポンサーリンク

ゴール:「“ありがちな事故パターン”を先に知っておいて、自分のジェネリクスを危険運転にしない」

ジェネリクスは強力だけど、そのぶん「やらかしポイント」も多いです。
しかも、やらかした瞬間はコンパイルが通るので、気づくのはだいたい本番かテストです。

ここでは、実務で本当にありがちな失敗パターンを
「なぜそれが危ないのか」「どう直すといいのか」まで含めて整理します。

あなたがこれから書くジェネリクスが、
同じ穴に落ちないようにするための“地雷マップ”だと思って読んでください。

失敗例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 };
}
TypeScript

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

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;
}
TypeScript

Value 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”ではなく、
実務で戦える“ちゃんと設計された型”になっていきます。

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