TypeScript:ジェネリクス入門
1学習の目的
- ジェネリクスで「どんな型でも扱える関数」を、型安全なまま書けるようになる。any を使わずに汎用的な処理を実現できる。
- 型に制約を付けたり、ジェネリックな型を定義したりできるようになる。実務のライブラリやAPIラッパーは、ほぼすべてこの仕組みで作られている。
2基礎解説
「配列の最初の要素を返す関数」を作りたいとします。数値配列・文字列配列・オブジェクト配列——すべてに対応したいが、any は使いたくない。そこでジェネリクスの出番です。
| やりたいこと | 書き方 | 意味 |
|---|---|---|
| 型を引数にする | function f<T>(x: T): T | Tは呼び出し時に決まる |
| 複数の型引数 | function f<A, B>(a: A, b: B) | いくつでも書ける |
| 型に制約を付ける | <T extends { length: number }> | length を持つ型だけ |
| ジェネリックな型 | type Box<T> = { value: T } | 型自体を部品化 |
- <T> は「型の変数」。関数を呼ぶときに実際の型が決まる。first([1,2,3]) なら T は number、first(["a"]) なら string になる。
- any との決定的な違いは、型情報が失われないこと。any だと戻り値も any になるが、ジェネリクスなら「入れた型がそのまま出てくる」。
- Tという名前に意味はない。慣習的に T(Type)・K(Key)・V(Value)が使われるが、<Item> のように分かりやすい名前でもよい。
- extends で制約を付けられる。<T extends { length: number }> と書けば、length を持つ型(文字列・配列など)だけを受け付ける。
- 型自体もジェネリックにできる。Box<number> と Box<string> のように、中身の型を後から指定する型を作れる。
現場使用例:APIレスポンスの共通ラッパー、配列操作のユーティリティ関数、状態管理ライブラリ、フォームの汎用コンポーネント。「型が違うだけで処理は同じ」という場面はすべて対象になる。
// ❌ any だと型情報が失われる
function firstAny(arr: any[]): any {
return arr[0];
}
const a = firstAny([1, 2, 3]); // a は any(number ではない)
// ✅ ジェネリクスなら型が保たれる
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const n = first([1, 2, 3]); // number | undefined
const s = first(["a", "b"]); // string | undefined
// 複数の型引数
function pair<A, B>(a: A, b: B): [A, B] {
return [a, b];
}
console.log(pair("x", 1)); // ['x', 1]
// 制約を付ける(length を持つ型だけ)
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
console.log(longest("abc", "ab")); // "abc"
console.log(longest([1, 2], [1, 2, 3])); // [1,2,3]
// 型自体をジェネリックにする
type Box<T> = { value: T; label: string };
const numBox: Box<number> = { value: 42, label: "数値" };
const strBox: Box<string> = { value: "あ", label: "文字" };
3基本ドリル(10問)
出力2行。1 / a
function first
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
console.log(first([1, 2, 3]));
console.log(first(["a", "b"]));
T は呼び出すときに決まる。数値配列を渡せば T は number、文字列配列なら string になり、戻り値の型もそれに追随する。
A. 速度が速い B. 型情報が失われない C. 記述が短い D. 違いはない 選択★☆☆無料
解答 == B
any を使うと戻り値の型はどうなるか。
B
any は戻り値も any になり、その後の処理で型チェックが効かなくなる。ジェネリクスなら「入れた型がそのまま出てくる」ので安全性が保たれる。
出力 == 1,a
function swap(a: A, b: B): [B, A] { return [b, a]; } と書く。
function swap<A, B>(a: A, b: B): [B, A] {
return [b, a];
}
console.log(swap("a", 1).join(","));
型引数は複数書ける。戻り値で順序を入れ替えていることも、型として [B, A] と正確に表現されている。
function last<____>(arr: T[]): T | undefined {
return arr[arr.length - 1];
}
console.log(last([1, 2, 3]));
出力 == 3
型引数の名前。慣習的に使われる大文字1文字。
T
T という名前に特別な意味はない。Type の頭文字を取った慣習で、<Item> のような分かりやすい名前でも構わない。
A. すべての型 B. length を持つ型だけ C. 数値だけ D. オブジェクトだけ 選択★☆☆無料
解答 == B
extends は制約を表す。
B
文字列や配列は length を持つので渡せるが、数値は渡せない。制約を付けることで、T に対して安全に x.length が使えるようになる。
出力2行。abcde / 1,2,3
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
console.log(longest("abcde", "abc"));
console.log(longest([1, 2], [1, 2, 3]).join(","));
制約が無ければ a.length でエラーになる。「T は length を持つ」と伝えることで、初めてそのプロパティが使える。
function size<T ____ { length: number }>(x: T): number {
return x.length;
}
console.log(size("hello"));
出力 == 5
「〜を満たす」を意味する7文字のキーワード。継承と同じ単語。
extends
ジェネリクスの extends は「継承」ではなく「制約」を意味する。「この条件を満たす型だけを受け付ける」という指定になる。
A. T B. number C. any D. unknown 選択★★☆無料
解答 == B
T に number を指定している。
B
型引数に具体的な型を渡すと、その型で置き換わる。Box<string> なら value は string になる。
出力2行。数値:42 / 文字:あ
type Box
type Box<T> = {
value: T;
label: string;
};
const numBox: Box<number> = { value: 42, label: "数値" };
const strBox: Box<string> = { value: "あ", label: "文字" };
console.log(`${numBox.label}:${numBox.value}`);
console.log(`${strBox.label}:${strBox.value}`);
型そのものを部品化できる。同じ構造で中身の型だけ違うデータを、1つの型定義で表現できる。
出力 == 1件,2件,3件
function mapAll
function mapAll<T, U>(arr: T[], fn: (item: T) => U): U[] {
return arr.map(fn);
}
console.log(mapAll([1, 2, 3], (n) => `${n}件`).join(","));
入力の型 T と出力の型 U を別々に表現している。標準の map もこれと同じ仕組みで型が付いており、JS09で使ったメソッドの内側が見えてくる。
4実践シナリオ(5問)
出力2行。20 / 0
function at
function at<T>(arr: T[], index: number, fallback: T): T {
return arr[index] ?? fallback;
}
console.log(at([10, 20, 30], 1, 0));
console.log(at([10, 20, 30], 99, 0));
既定値の型も T なので、配列と違う型を渡そうとするとエラーになる。数値配列に文字列の既定値、という不整合が起きない。
出力2行。10 / なし
type Result
type Result<T> =
| { kind: "ok"; data: T }
| { kind: "err"; message: string };
function unwrap<T>(r: Result<T>, fallback: T): T {
return r.kind === "ok" ? r.data : fallback;
}
console.log(unwrap<number>({ kind: "ok", data: 10 }, 0));
console.log(unwrap<string>({ kind: "err", message: "失敗" }, "なし"));
TS09のタグ付きユニオンをジェネリックにした形。どんな型のデータでも、同じ成功/失敗の仕組みで扱える——実務のAPIラッパーはこの形が定番。
出力2行。1,2,3 / a,b
Set を使うと簡潔に書ける。[...new Set(arr)] で重複が消える。
function unique<T>(arr: T[]): T[] {
return [...new Set(arr)];
}
console.log(unique([1, 2, 2, 3, 1]).join(","));
console.log(unique(["a", "b", "a"]).join(","));
型が違っても処理は同じ——まさにジェネリクスが活きる場面。any で書けば動くが、戻り値が any[] になり、その後の処理で型が守られなくなる。
出力 == PC:2, 周辺機器:1
戻り値の型は Record
function countBy<T>(arr: T[], key: (item: T) => string): Record<string, number> {
const result: Record<string, number> = {};
for (const item of arr) {
const k = key(item);
result[k] = (result[k] ?? 0) + 1;
}
return result;
}
const products = [
{ name: "ノートPC", category: "PC" },
{ name: "マウス", category: "周辺機器" },
{ name: "モニター", category: "PC" },
];
const counts = countBy(products, (p) => p.category);
console.log(
Object.entries(counts)
.map(([k, v]) => `${k}:${v}`)
.join(", ")
);
キーの取り出し方を関数として外から渡しているので、どんなデータでも集計できる。Record<string, number> は「文字列をキーに数値を持つオブジェクト」を表す型。
出力2行。マウス / 該当なし
function findById<T extends { id: number }>(
items: T[],
id: number
): T | undefined {
return items.find((item) => item.id === id);
}
const products = [
{ id: 1, name: "ノートPC" },
{ id: 2, name: "マウス" },
];
for (const id of [2, 99]) {
const found = findById(products, id);
console.log(found?.name ?? "該当なし");
}
制約により id を持たない型は渡せない。それでいて戻り値は元の型そのまま(Product)なので、found.name のように固有のプロパティにアクセスできる。
5仕上げ課題
作る関数(すべてジェネリック)
① first<T>(arr: T[]): T | undefined … 最初の要素
② unique<T>(arr: T[]): T[] … 重複を除去
③ sumBy<T>(arr: T[], fn: (item: T) => number): number … 数値を取り出して合計
④ groupBy<T>(arr: T[], key: (item: T) => string): Record<string, T[]> … キーごとに分類
データ:商品配列
・{ name: "ノートPC", category: "PC", price: 128000 }
・{ name: "マウス", category: "周辺機器", price: 3200 }
・{ name: "モニター", category: "PC", price: 45000 }
・{ name: "キーボード", category: "周辺機器", price: 8500 }
出力仕様(5行)
最初の商品:ノートPC
カテゴリ:PC, 周辺機器
合計金額:184,700円
PC:ノートPC, モニター
周辺機器:マウス, キーボード
出力5行が完全一致
groupBy は Record
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
function unique<T>(arr: T[]): T[] {
return [...new Set(arr)];
}
function sumBy<T>(arr: T[], fn: (item: T) => number): number {
return arr.reduce((sum, item) => sum + fn(item), 0);
}
function groupBy<T>(
arr: T[],
key: (item: T) => string
): Record<string, T[]> {
const result: Record<string, T[]> = {};
for (const item of arr) {
const k = key(item);
if (!result[k]) {
result[k] = [];
}
result[k].push(item);
}
return result;
}
const products = [
{ name: "ノートPC", category: "PC", price: 128000 },
{ name: "マウス", category: "周辺機器", price: 3200 },
{ name: "モニター", category: "PC", price: 45000 },
{ name: "キーボード", category: "周辺機器", price: 8500 },
];
console.log(`最初の商品:${first(products)?.name ?? "なし"}`);
const categories = unique(products.map((p) => p.category));
console.log(`カテゴリ:${categories.join(", ")}`);
const total = sumBy(products, (p) => p.price);
console.log(`合計金額:${total.toLocaleString()}円`);
const grouped = groupBy(products, (p) => p.category);
for (const [category, items] of Object.entries(grouped)) {
console.log(`${category}:${items.map((i) => i.name).join(", ")}`);
}
4つの関数はすべて「商品」を一切知らない。それでも商品配列を正しく処理できている——これがジェネリクスの威力だ。
もし any で書いていたらどうなるか。first(products) の戻り値は any になり、first(products).nmae のような打ち間違いも素通りする。汎用性と安全性は両立できるということを、この4つの関数が示している。
とくに groupBy の戻り値 Record<string, T[]> に注目してほしい。「文字列のキーに、T の配列が対応する」——商品を渡せば Record<string, Product[]> になるので、items.map((i) => i.name) と書いたときに i が Product だと分かる。型が最後まで追跡されている。
実務で使うライブラリ(lodash、React、状態管理ツール)は、ほぼすべてこの仕組みで作られている。ジェネリクスが読めるようになると、ライブラリの型定義が読めるようになり、使い方を推測できるようになる。
次章では配列操作と型を扱う。map・filter・reduce に、実際にどう型が付いているのかを見ていく。