TypeScriptをしばらく書いているけど、ジェネリクスがStack Overflowからコピペするだけで本当に理解できていないと感じているなら、
この記事がそれを解決します。
ジェネリクスはTypeScriptを「型アノテーション付きのJavaScript」から
真に表現力豊かな型システムへと引き上げる機能です。一度理解できると、いたるところで見つかります —
そして困った時にanyに逃げる代わりに、自然とジェネリクスを使うようになります。
TypeScriptハンドブックのジェネリクスの章
が完全な仕様をカバーしていますが、この記事では実際のコードベースで使うパターンに焦点を当てます。
anyの問題点
ジェネリクスは特定の問題を解決するために存在します:関数やデータ構造を複数の型で動作させたいが、
その過程で型情報を失いたくない。
安易な解決策はanyです — 何を失ったかに気づくまでは良さそうに見えます:
// With any — TypeScript has no idea what comes out
function identity(arg: any): any {
return arg;
}
const result = identity('hello');
// result is typed as 'any' — you've lost the string type
// TypeScript won't catch this:
result.toFixed(2); // no error at compile time, crashes at runtime型情報は入っていきますが、出てきません。resultに何をしても
まったくチェックされません。それはTypeScriptのコードベースではありません — 余計なステップのあるJavaScriptです。
ジェネリクスはこれを解決します:「まだ正確な型はわからないが、
入ったものは同じ型で出てきてほしい」と言えるようにします。
// With a generic — T flows through the function
function identity<T>(arg: T): T {
return arg;
}
const result = identity('hello');
// result is typed as 'string' ✅
result.toFixed(2); // ❌ TypeScript catches this: Property 'toFixed' does not exist on type 'string'
const count = identity(42);
// count is typed as 'number' ✅<T>は型パラメーターを宣言します — 型のための変数と考えてください。
TypeScriptは渡した引数からTが何かを推論するので、明示的に書く必要はほとんどありません。
identity<string>('hello')も動きますが、
identity('hello')でも動きます — TypeScriptが推論します。
実践でのジェネリック関数
identity関数は典型的な教育例ですが、実際には書きません。 実際のコードベースに出てくるジェネリック関数のパターンを紹介します:
// A type-safe array first/last helper
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
function last<T>(arr: T[]): T | undefined {
return arr[arr.length - 1];
}
const firstUser = first(users); // UserProfile | undefined
const lastOrder = last(orders); // Order | undefined
const firstNum = first([1, 2, 3]); // number | undefined
// Group array items by a key
function groupBy<T, K extends string | number>(
items: T[],
getKey: (item: T) => K
): Record<K, T[]> {
return items.reduce((acc, item) => {
const key = getKey(item);
if (!acc[key]) acc[key] = [];
acc[key].push(item);
return acc;
}, {} as Record<K, T[]>);
}
// TypeScript infers T as Order and K as string
const byStatus = groupBy(orders, order => order.status);
// byStatus: Record<string, Order[]>
// byStatus['pending'] is Order[] ✅TypeScriptが関数の呼び出し方からTとKを自動的に推論することに注目してください。
呼び出し側で型アノテーションを1つも書かずに、戻り値に完全な型安全性が得られます。それが利点です。
ジェネリックインターフェースと型
ここがジェネリクスが日常業務に不可欠になるところです。APIと通信するコードベースは いくつかのジェネリックラッパー型が必要になります。常に見かけ(そして書く)パターンを紹介します:
// API response wrapper — used across every endpoint
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: string;
}
// Paginated list response
interface PaginatedResult<T> {
items: T[];
page: number;
pageSize: number;
totalItems: number;
totalPages: number;
}
// Using them with concrete types
interface UserProfile {
id: number;
name: string;
email: string;
avatarUrl: string;
}
interface Order {
id: number;
userId: number;
total: number;
status: 'pending' | 'shipped' | 'delivered';
}
// The return types are fully typed — no casting needed downstream
async function getUser(id: number): Promise<ApiResponse<UserProfile>> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
async function listOrders(page: number): Promise<PaginatedResult<Order>> {
const res = await fetch(`/api/orders?page=${page}`);
return res.json();
}
const response = await getUser(1);
response.data.email; // ✅ string
response.data.avatarUrl; // ✅ string
const result = await listOrders(1);
result.items[0].status; // ✅ 'pending' | 'shipped' | 'delivered'
result.totalPages; // ✅ numberUserProfile、Order)を自動的に生成できます。
その後、ジェネリックラッパーに組み込みます。ジェネリック制約
複数の型は受け入れたいが、絶対にどんな型でも良いわけではない場合があります。
ジェネリック制約で型パラメーターが持つべきものを指定できます。構文は
T extends SomeType — 「TはSomeTypeに割り当て可能でなければならない」という意味です:
// Without constraint — TypeScript doesn't know T has an id property
function findById<T>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id); // ❌ Property 'id' does not exist on type 'T'
}
// With constraint — T must have at least an id: number field
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id); // ✅
}
// Works with any type that has id: number
const user = findById(users, 42); // UserProfile | undefined
const order = findById(orders, 101); // Order | undefined
// Another common constraint — T must be an object (excludes primitives)
function mergeDefaults<T extends object>(partial: Partial<T>, defaults: T): T {
return { ...defaults, ...partial };
}
// T must have a name: string and email: string
function formatContact<T extends { name: string; email: string }>(contact: T): string {
return `${contact.name} <${contact.email}>`;
}
// Works on any object with those two fields — UserProfile, Employee, whatever
formatContact({ name: 'Alice', email: '[email protected]', role: 'admin' }); // ✅制約はTを正確に{ id: number }にロックするわけではありません —
Tは少なくともその形を持つべきという意味です。10フィールドある
UserProfile全体を渡しても問題ありません。これが構造的型付けの実践で、
TypeScriptの最も強力で
よく文書化された
機能の一つです。
keyof制約
TypeScript標準ライブラリや実際のコードベースで最も有用なジェネリックパターンの一つは、
ジェネリクスとkeyofの組み合わせです。オブジェクト型のプロパティ名を受け入れ、
戻り値の型がそのプロパティに対応することを保証する関数を書けます。
// keyof T is the union of all keys of T as string literals
// K extends keyof T means K must be one of those keys
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user: UserProfile = {
id: 1,
name: 'Alice',
email: '[email protected]',
avatarUrl: 'https://example.com/avatar.png'
};
const name = getProperty(user, 'name'); // string ✅
const id = getProperty(user, 'id'); // number ✅
const email = getProperty(user, 'email'); // string ✅
getProperty(user, 'password'); // ❌ Argument of type '"password"' is not assignable to
// parameter of type 'keyof UserProfile'
// Real use case: a generic sort function
function sortBy<T>(items: T[], key: keyof T): T[] {
return [...items].sort((a, b) => {
const av = a[key];
const bv = b[key];
return av < bv ? -1 : av > bv ? 1 : 0;
});
}
const byName = sortBy(users, 'name'); // ✅ sorted by name
const byId = sortBy(users, 'id'); // ✅ sorted by id
sortBy(users, 'nonexistent'); // ❌ caught at compile timeデフォルト型パラメーター
関数パラメーターがデフォルト値を持てるように、型パラメーターも持てます。これは ほとんど常に特定の型と一緒に使われるジェネリック型があるが、 そうでない場合の柔軟性を維持したい時に便利です。
// Default type parameter — T defaults to string if not specified
interface Cache<T = string> {
get(key: string): T | undefined;
set(key: string, value: T, ttlMs?: number): void;
delete(key: string): void;
clear(): void;
}
// Without specifying T — uses the default (string)
declare const stringCache: Cache;
const val = stringCache.get('theme'); // string | undefined
// Specifying a different T
declare const userCache: Cache<UserProfile>;
const user2 = userCache.get('user:42'); // UserProfile | undefined
// Another useful example: an event emitter with a typed payload
interface TypedEvent<TPayload = void> {
subscribe(handler: (payload: TPayload) => void): () => void;
emit(payload: TPayload): void;
}
// Events with no payload — default void keeps the API clean
const appReady: TypedEvent = { /* ... */ };
appReady.emit(); // no argument needed
// Events with a payload
const userLoggedIn: TypedEvent<{ userId: number; timestamp: Date }> = { /* ... */ };
userLoggedIn.emit({ userId: 42, timestamp: new Date() });組み込みジェネリックユーティリティ型
TypeScriptには、上で学んだジェネリクスを使って実装された 組み込みユーティリティ型 のセットが付いてきます。これらの使い方を理解することは、最も実用的なTypeScriptスキルの一つです。 出力型を示す実例を交えた基本セットを紹介します:
interface UserProfile {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'viewer';
passwordHash: string;
createdAt: Date;
}
// Partial<T> — all fields become optional (great for PATCH payloads)
type UpdateUserPayload = Partial<UserProfile>;
// { id?: number; name?: string; email?: string; role?: ...; ... }
// Required<T> — all fields become required (reverse of Partial)
interface DraftConfig {
apiUrl?: string;
timeout?: number;
maxRetries?: number;
}
type ResolvedConfig = Required<DraftConfig>;
// { apiUrl: string; timeout: number; maxRetries: number }
// Pick<T, K> — keep only the named fields
type UserSummary = Pick<UserProfile, 'id' | 'name' | 'email'>;
// { id: number; name: string; email: string }
// Use in list views — only ship what the UI needs
// Omit<T, K> — exclude the named fields
type PublicUserProfile = Omit<UserProfile, 'passwordHash'>;
// { id: number; name: string; email: string; role: ...; createdAt: Date }
// Safe to include in API responses
// Record<K, V> — typed dictionary / map
type PermissionMap = Record<UserProfile['role'], string[]>;
// { admin: string[]; user: string[]; viewer: string[] }
const permissions: PermissionMap = {
admin: ['read', 'write', 'delete', 'admin'],
user: ['read', 'write'],
viewer: ['read']
};
// ReturnType<T> — infer what a function returns (keeps types in sync automatically)
function buildUserSession(user: UserProfile) {
return {
token: crypto.randomUUID(),
userId: user.id,
role: user.role,
expiresAt: new Date(Date.now() + 3_600_000)
};
}
type UserSession = ReturnType<typeof buildUserSession>;
// { token: string; userId: number; role: 'admin' | 'user' | 'viewer'; expiresAt: Date }
// Change buildUserSession and UserSession updates automatically ✅既存のJavaScriptをTypeScriptに変換してゼロからこれらの型を構築している場合、 JSからTypeScriptツールが最初の型を設定するのを助けてくれるので、 その上にユーティリティ型を重ねていけます。
現実的なエンドツーエンドの例:型付きAPIクライアント
型付きAPIレイヤーを構築する度に使うパターンを紹介します。 ジェネリック関数、ジェネリックインターフェース、ユーティリティ型を組み合わせて、 fetchの呼び出しからデータを消費するコンポーネントまでエンドツーエンドの型安全性を提供します:
// The response envelope — wraps every API response
interface ApiResponse<T> {
data: T;
meta: {
requestId: string;
duration: number;
};
}
// Error envelope — what the API sends on failure
interface ApiError {
code: string;
message: string;
details?: Record<string, string[]>;
}
// The generic fetch wrapper
async function fetchApi<T>(
url: string,
options?: RequestInit
): Promise<ApiResponse<T>> {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...options?.headers },
...options
});
if (!res.ok) {
const err: ApiError = await res.json();
throw new Error(`[${err.code}] ${err.message}`);
}
return res.json();
}
// Typed endpoint functions — T is set at each call site
async function getUser(id: number): Promise<UserProfile> {
const { data } = await fetchApi<UserProfile>(`/api/users/${id}`);
return data;
}
async function listOrders(userId: number, page = 1): Promise<PaginatedResult<Order>> {
const { data } = await fetchApi<PaginatedResult<Order>>(
`/api/users/${userId}/orders?page=${page}`
);
return data;
}
async function createOrder(
payload: Pick<Order, 'userId' | 'total'>
): Promise<Order> {
const { data } = await fetchApi<Order>('/api/orders', {
method: 'POST',
body: JSON.stringify(payload)
});
return data;
}
// Usage — fully typed, no casting anywhere
const user = await getUser(42);
console.log(user.email); // string ✅
const orders = await listOrders(42);
orders.items[0].status; // 'pending' | 'shipped' | 'delivered' ✅
orders.totalPages; // number ✅
const newOrder = await createOrder({ userId: 42, total: 99.99 });
newOrder.id; // number ✅よくある間違い
ジェネリクスに慣れたら避けるべきパターンをいくつか紹介します:
- ジェネリックの中で
anyを使う。function wrap<T>(val: T): anyと書くと、目的を破壊しています。 ポイント全体は型が流れることです — 戻り値の型や実装内でanyを使うと TypeScriptはもう追跡できません。 - 特定の型で過度に制約する。
T extends { id: number }だけで十分な時にfunction process<T extends UserProfile>と書くのは制約が強すぎます。 実装が機能する最小限の制約を使いましょう — そうすることで関数は 同じ形を持つ異なる型にわたって再利用可能のままです。 - ユニオンで十分な時にジェネリクスに頼る。
関数が
stringかnumberのどちらかを取り、それぞれでロジックが異なる場合、 内部にtype guardを持つfunction f(arg: string | number)はジェネリックより明確です。 ジェネリクスが輝くのは同じロジックが全型バリアントに適用される時です。 - 型パラメーターが多すぎる。
<T, U, V, W>と書いていたら立ち止まってください。 それは通常、関数がやりすぎているか、型を単一のインターフェースとして表現できるサインです。 TypeScriptコンパイラのソースコード自体 は良い参考資料です — 複雑なユーティリティでも2〜3個の型パラメーターで十分なことがほとんどです。
まとめ
ジェネリクスはTypeScriptが「アノテーション付きJavaScript」から
真に強力な型システムになる転換点です。中心となるアイデアはシンプルです:型をパラメーターとして取り込み、
情報を失わずに関数やインターフェースを通じて流れるようにします。そこから制約で
何が受け入れ可能かを絞り込み、keyofで型安全なプロパティアクセスができ、
組み込みユーティリティ型(Partial、Pick、Omit、
Record)が手書きするパターンを代わりにさばきます。
最良の次のステップは実際のプロジェクトを開き、他の方法がわからずanyを使った関数を見つけて、
ジェネリックに置き換えることです。
TypeScriptハンドブックのジェネリクス
と
ユーティリティ型のリファレンス
はブックマークする価値のある2ページです。実際のAPIペイロードからインターフェースを構築している場合、
JSONからTypeScriptが具体的な型を生成できます —
あとはApiResponse<T>やPaginatedResult<T>のような
ジェネリックユーティリティ型でラップするだけで、エンドツーエンドの型安全性が得られます。