TypeScript | 関数・クラス・ジェネリクス:ジェネリクス基礎 - ジェネリック関数の基本

TypeScript TypeScript
スポンサーリンク

ゴール:「型だけ違う同じ関数」を、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];
}
TypeScript

firstAny の戻り値は常に any
「何が返ってくるか」が型から分かりません。

firstGeneric の戻り値は「渡した配列の要素型」によって変わります。

firstGeneric([1, 2, 3])number | undefined
firstGeneric(["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 を持たない
TypeScript

T 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 は“あとで決まる型の穴”なんだな。
同じロジックを、型だけ変えて使い回せるんだな。」

と感じられたら、
ジェネリック関数の基礎はもうしっかり掴めています。

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