TypeScript

TypeScriptのジェネリクス入門:型推論・制約・共通関数の作り方

この記事でわかること

TypeScriptのジェネリクスを型推論・extends・keyofから解説。空の配列を扱う戻り値と、unknownを検証してJSONを読み込む実践例を掲載します。

ジェネリクスは、引数と戻り値などの型の関係を保ちながら、同じ処理を複数の型で使うための仕組みです。any に置き換えて型情報を捨てることなく、配列やオブジェクトを扱う共通関数を作れます。

この記事では関数・配列・オブジェクト型を前提に、型パラメータ、型推論、extends による制約、外部入力の検証まで説明します。掲載コードはTypeScript 4.9.4の strictnoUncheckedIndexedAccess を有効にして検証しています。

型パラメータと型引数の違い

関数を定義するときの <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 が推論されます。戻り値の型は常に広い stringnumber になるとは限りません。変数宣言の方法や呼び出す関数によって、リテラル型が広がる場合があります。

inferred.toFixed(2) は文字列に存在しないメソッドなので型エラーになります。戻り値を any にすると、こうした確認が働かなくなります。ジェネリクスの公式解説

空の配列を考慮した共通関数を作る

型パラメータに制約がなければ、Tlength があるとは限りません。一方、引数を 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] は選んだプロパティの型なので、上の namestring になります。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などは別途検証します。

関連記事

TypeScriptとは・学習順・目的別の記事一覧へ戻る

y.
WRITTEN BY

y_ymo10

SEの部屋で、JavaScript・TypeScript・React.js・Next.jsの開発ノートを公開しています。

ほかの開発ノートを読む →