TypeScriptのジェネリクス入門:型推論・制約・共通関数の作り方
TypeScriptのジェネリクスを型推論・extends・keyofから解説。空の配列を扱う戻り値と、unknownを検証してJSONを読み込む実践例を掲載します。
ジェネリクスは、引数と戻り値などの型の関係を保ちながら、同じ処理を複数の型で使うための仕組みです。any に置き換えて型情報を捨てることなく、配列やオブジェクトを扱う共通関数を作れます。
この記事では関数・配列・オブジェクト型を前提に、型パラメータ、型推論、extends による制約、外部入力の検証まで説明します。掲載コードはTypeScript 4.9.4の strict と noUncheckedIndexedAccess を有効にして検証しています。
型パラメータと型引数の違い
関数を定義するときの <T> は型パラメータ、呼び出しで指定する <string> は型引数です。T は慣習的な名前で、TValue などに変えても動作は同じです。
ファイル: src/identity.ts
export function identity<T>(value: T): T {
return value;
}
const explicit = identity<string>("hello"); // string
const inferred = identity("hello"); // "hello"
const count = identity(123); // 123
console.log(explicit.toUpperCase()); // HELLO
console.log(inferred.length); // 5
console.log(count.toFixed(1)); // 123.0
この例の const inferred には文字列リテラル型 "hello"、const count には数値リテラル型 123 が推論されます。戻り値の型は常に広い string や number になるとは限りません。変数宣言の方法や呼び出す関数によって、リテラル型が広がる場合があります。
inferred.toFixed(2) は文字列に存在しないメソッドなので型エラーになります。戻り値を any にすると、こうした確認が働かなくなります。ジェネリクスの公式解説
空の配列を考慮した共通関数を作る
型パラメータに制約がなければ、T に length があるとは限りません。一方、引数を readonly T[] にすれば、配列としての操作と要素型の関係を表せます。
ファイル: src/collections.ts
export function first<T>(values: readonly T[]): T | undefined {
return values[0];
}
export function getProperty<T, K extends keyof T>(
object: T,
key: K,
): T[K] {
return object[key];
}
export function withLength<T extends { length: number }>(value: T): T {
console.log(value.length);
return value;
}
const empty = first<string>([]);
const firstName = first(["あかり", "そら"]);
const member = { id: 7, name: "あかり" };
const name = getProperty(member, "name"); // string
console.log(empty ?? "なし"); // なし
console.log(firstName?.toUpperCase()); // あかり
console.log(name); // あかり
console.log(withLength("TypeScript")); // 長さ10を表示して元の文字列を返す
first() の戻り値に undefined が含まれるのは、配列が空かもしれないためです。型パラメータを使っても、配列に値が存在する保証は生まれません。readonly な配列を受け取ることで、変更しない関数に読み取り専用の配列も渡せます。
K extends keyof T は、指定できるキーを対象の型が持つキーに制限します。getProperty(member, "missing") は型エラーです。戻り値の T[K] は選んだプロパティの型なので、上の name は string になります。keyofの公式解説
withLength() の制約は、数値の length を持つことです。文字列や配列には使えますが、withLength(42) は型エラーになります。制約で必要な性質を示してから、そのプロパティを利用します。
型引数だけではJSONを検証できない
JSON.parse() の結果を as T で返すだけの関数は、JSONの構造を確認しません。型引数に User を指定しても、入力に必要なプロパティが追加されるわけではありません。ここでは、unknown を検証する関数を別の引数で受け取ります。
ファイル: src/parse-user.ts
export type User = { id: number; name: string };
export function parseJson<T>(text: string, decode: (value: unknown) => T): T {
const value: unknown = JSON.parse(text);
return decode(value);
}
export function decodeUser(value: unknown): User {
if (
typeof value !== "object" ||
value === null ||
!("id" in value) ||
!("name" in value) ||
typeof value.id !== "number" ||
!Number.isInteger(value.id) ||
value.id < 1 ||
typeof value.name !== "string" ||
value.name.trim() === ""
) {
throw new Error("ユーザーの形式が正しくありません");
}
return { id: value.id, name: value.name };
}
try {
const user = parseJson('{"id":7,"name":"あかり"}', decodeUser);
console.log(user.name); // あかり
} catch (error: unknown) {
console.error(error instanceof Error ? error.message : "不明なエラー");
}
parseJson() の T は、検証関数の戻り値から User と推論されます。JSON構文が不正なら JSON.parse() が例外を投げ、構造が不正なら decodeUser() が例外を投げます。名前を取得できることだけでなく、IDが正の整数であることも実行時に確認しています。
decodeUser() は必要な項目だけを持つ新しいオブジェクトを返します。複雑なAPIではスキーマを使う検証ライブラリも選択肢ですが、検証が必要という点は同じです。ジェネリクスは検証関数の正しさまで証明しないため、不正な入力のテストも用意します。unknownの絞り込み
実行方法
空の作業用フォルダで npm init -y を実行し、npm install --save-dev typescript @types/node で開発用依存関係を入れます。Node.jsと型定義のバージョンを対応させ、上の3ファイルを src に保存してください。
ファイル: tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmitOnError": true,
"rootDir": "src",
"outDir": "dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
npx tsc -p tsconfig.json
node dist/identity.js
node dist/collections.js
node dist/parse-user.js
各ファイルは独立して実行できます。ジェネリクスの型パラメータは生成したJavaScriptには残らず、実行時には通常の関数として動きます。
よくある質問
すべての関数をジェネリクスにするべきですか?
必要ありません。入力の型を戻り値や別の引数に結び付けたいときに役立ちます。文字列の長さだけを返すなら、引数を string、戻り値を number とする単純な関数で十分です。
型エラーを as T で消してもよいですか?
まず、要求している操作がすべての T に対して成立するかを確認します。必要なプロパティがあるなら制約を追加し、外部入力なら検証します。根拠のない as T は、入力と戻り値の関係が正しくない実装を隠してしまいます。
ジェネリクスを使う必要があるか判断する
入出力の型の関係を残す
同じ関数に文字列配列と数値配列を渡したとき、結果の型もそれぞれに対応させたい場面が対象です。処理が一つの具体的な型だけを扱うなら、型パラメーターを増やさずに記述できます。
制約は処理に必要な範囲にする
extendsで要求するプロパティを絞ると、利用できる値の幅を残せます。無関係な項目まで要求すると共通化の効果が下がります。型引数は外部入力の検査を代行しないため、JSONなどは別途検証します。