ゴール:「型だけ違う同じ関数」を、1本のジェネリック関数で書けるようになる
ジェネリック関数の本質はとてもシンプルです。
「型だけ違って、やっていることは同じ関数」を、1本にまとめるための書き方
です。
<T> という「型の変数」を導入して、
その T に、呼び出し時に具体的な型(number や string など)を差し込む——
これがジェネリック関数の基本イメージです。
ここから、実際のコードで感覚をつかんでいきましょう。
まずは“ジェネリックじゃない”関数から出発する
型ごとに同じ関数を量産してしまうパターン
例えば、「配列の先頭要素を返す関数」を考えます。
function firstNumber(arr: number[]): number | undefined {
return arr[0];
}
const n = firstNumber([1, 2, 3]); // n: number | undefined
TypeScriptこれを string でも使いたくなったら、こうなります。
function firstString(arr: string[]): string | undefined {
return arr[0];
}
const s = firstString(["a", "b", "c"]); // s: string | undefined
TypeScriptロジックはまったく同じなのに、
「型が違うだけ」で関数を増やしている状態です。
このままいくと、User 用、Product 用…と、
同じ関数が型ごとに量産されていきます。
any でごまかすと何が起きるか
「じゃあ any にしちゃえば?」とやるとこうなります。
function firstAny(arr: any[]): any {
return arr[0];
}
const x = firstAny([1, 2, 3]); // x: any
const y = firstAny(["a", "b", "c"]); // y: any
TypeScript確かに、どんな配列でも受け取れるようにはなりました。
でも戻り値が any なので、y.toUpperCase() と書いても、y.hogehoge() と書いても、コンパイラは何も教えてくれません。
「型の情報を捨ててしまっている」状態です。
ジェネリック関数の基本形:型パラメータ <T> を導入する
first 関数をジェネリックに書き直す
さっきの firstNumber / firstString を、
ジェネリック関数 1 本にまとめてみます。
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const n = first([1, 2, 3]); // n: number | undefined
const s = first(["a", "b", "c"]); // s: string | undefined
TypeScriptここでのポイントは、<T> です。
これは「型の変数」です。
関数の定義を、こう読む癖をつけてください。
「first は、型パラメータ T を受け取る関数。
引数 arr は T の配列で、戻り値は T または undefined。」
呼び出し時には、T に具体的な型が入ります。
first<number>([1, 2, 3]) と明示してもいいし、first([1, 2, 3]) と書けば TypeScript が T=number と推論してくれます。
any との決定的な違い
any 版と並べてみます。
function firstAny(arr: any[]): any {
return arr[0];
}
function firstGeneric<T>(arr: T[]): T | undefined {
return arr[0];
}
TypeScriptfirstAny の戻り値は常に any。
「何が返ってくるか」が型から分かりません。
firstGeneric の戻り値は「渡した配列の要素型」によって変わります。
firstGeneric([1, 2, 3]) → number | undefinedfirstGeneric(["a", "b"]) → string | undefined
つまりジェネリック関数は、
「汎用的に書きつつ、呼び出しごとに“ちゃんとした型”を保つ」
ための仕組みです。
identity 関数で「型の穴」の感覚をつかむ
一番シンプルなジェネリック関数
ジェネリック関数の入門として定番なのが、identity 関数です。
function identity<T>(value: T): T {
return value;
}
const a = identity<number>(1); // a: number
const b = identity("hello"); // b: string(T は string と推論)
TypeScript読み方はこうです。
「identity は、型パラメータ T を受け取る。
引数 value は T 型で、戻り値も T 型。」
ここで大事なのは、
「関数の中では T としか書いていないのに、
呼び出し時には number や string として振る舞う」
というところです。
T は「あとから決まる型の穴」だ、と感じられれば OK です。
型パラメータを明示する/推論に任せる
ジェネリック関数は、型パラメータを明示してもいいし、
TypeScript に推論させても構いません。
identity<number>(1); // 明示
identity(1); // 推論(T=number)
TypeScript最初は明示してみて、
「ここに入るのが T なんだな」と意識すると理解しやすいです。
慣れてきたら、ほとんどの場合は推論に任せて書くことが多いです。
複数の型パラメータを持つジェネリック関数
2つの値をタプルにする関数
型パラメータは 1 つである必要はありません。
function pair<T, U>(a: T, b: U): [T, U] {
return [a, b];
}
const p1 = pair(1, "a"); // [number, string]
const p2 = pair(true, { x: 1 }); // [boolean, { x: number }]
TypeScriptここでは、
T は第1引数の型
U は第2引数の型
戻り値は [T, U] というタプル型
になっています。
「型の変数が 2 つあるだけで、やっていることは identity と同じ」です。
「値の関係性(a と b をペアにする)を、型の関係性(T と U のタプル)としても表現できる」
これがジェネリック関数の気持ちよさです。
ジェネリック関数が“嬉しい”と感じる具体例
配列を逆順にする関数
どんな型の配列でも逆順にしたい、という関数。
function reverse<T>(arr: T[]): T[] {
return [...arr].reverse();
}
const nums = reverse([1, 2, 3]); // number[]
const strs = reverse(["a", "b", "c"]); // string[]
TypeScriptここでの T は「配列の要素型」です。
reverse の中身は T だけを見て書かれているのに、
呼び出し側ではちゃんと number[] や string[] として扱えます。
もしこれを any で書いていたら、
戻り値は any[] になってしまい、
その後のコードで型の恩恵を受けられません。
オブジェクトから特定のキーの値を取り出す関数
少しだけレベルを上げてみます。
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "Taro" };
const id = getProp(user, "id"); // id: number
const name = getProp(user, "name"); // name: string
// getProp(user, "age"); // エラー: "age" は存在しない
TypeScriptここでは、
T はオブジェクト全体の型
K は T のキーのどれか(keyof T)
戻り値の型は T[K](そのキーに対応する値の型)
という関係になっています。
この例は少し高度ですが、
「ジェネリクスを使うと、値どうしの関係を型でも表現できる」
ということがよく分かるパターンです。
ジェネリック関数に“制約”をつける
T に「なんでも入れていい」わけではない場合
例えば、「length プロパティを持つものだけ受け取りたい」関数。
function getLength<T extends { length: number }>(value: T): number {
return value.length;
}
getLength("hello"); // OK(string は length を持つ)
getLength([1, 2, 3]); // OK(配列も length を持つ)
getLength({ length: 10 }); // OK
// getLength(123); // エラー:number は length を持たない
TypeScriptT extends { length: number } が「型パラメータ T の制約」です。
ここでやっていることは、
「T はなんでもいいけど、少なくとも length: number を持っていてね」
という条件をつけている、ということです。
これにより、
関数の中では value.length を安心して使える
呼び出し側は「length を持たない型」を渡すとコンパイルエラーになる
という、かなり気持ちいい状態になります。
どんなときに「ジェネリック関数にしよう」と判断すべきか
キーワードは「ロジックは同じで、型だけ違う」
ジェネリック関数にするか迷ったら、
自分のコードをこう眺めてみてください。
同じ処理を、型だけ変えて何度も書いていないか?any に逃げて、型の情報を捨てていないか?
もし心当たりがあるなら、
そこはジェネリック関数の候補です。
特に、
配列を操作する関数(first, last, reverse, map など)
オブジェクトから値を取り出す関数
「入れたものと同じ型で出したい」関数(identity 的なもの)
は、ジェネリクスと相性がとてもいいです。
まとめ:ジェネリック関数の基本を自分の言葉で説明すると
最後に、あなた自身の言葉でこう整理してみてください。
ジェネリック関数とは、
「型に“変数”を導入して、あとから具体的な型を差し込める関数」。<T> のような型パラメータを使うことで、
1つの関数を、いろいろな型に対して再利用できる。
any と違って、
呼び出しごとに「ちゃんとした具体的な型」が保たれるので、
汎用性と型安全を両立できる。
まずは次の 2 つを、自分の手で書いてみてください。
function identity<T>(value: T): T { return value; }
function first<T>(arr: T[]): T | undefined { return arr[0]; }
TypeScriptそして、number や string、オブジェクトなど、
いろんな型を T に差し込んでみてください。
そこで、
「T は“あとで決まる型の穴”なんだな。
同じロジックを、型だけ変えて使い回せるんだな。」
と感じられたら、
ジェネリック関数の基礎はもうしっかり掴めています。
