ウェブエンジニア問題集

ジェネリクス — 型を引数にする

そもそもジェネリクスとは何か

関数は「値」を引数として受け取りますが、ジェネリクスは「型」を引数として受け取る仕組みです。

まだピンとこないと思うので、具体的な問題から見ていきます。

学習者学習者

「型を引数にする」…?言葉からして難しそう。普通の関数と何が違うの?

悩んでいる人のイラスト

ジェネリクスがない世界

配列の先頭要素を返す関数を作りたいとします。

function getFirstString(arr: string[]): string {
  return arr[0];
}
 
function getFirstNumber(arr: number[]): number {
  return arr[0];
}

やっていることは同じなのに、型が違うだけで関数が増えていきます。

any で解決?

「じゃあ any にすればいいのでは?」と思うかもしれません。

function getFirst(arr: any[]): any {
  return arr[0];
}
 
const result = getFirst([1, 2, 3]);
// result は any — number のメソッド補完が効かない
result.toFixed(2); // 動くけど型エラーにならない
result.toUpperCase(); // これも型エラーにならない(実行時に壊れる)

any にすると柔軟にはなりますが、戻り値の型情報が消えます。TypeScript を使う意味がなくなってしまいます。

ジェネリクスで解決する

function getFirst<T>(arr: T[]): T {
  return arr[0];
}
 
const a = getFirst(['hello', 'world']); // string
const b = getFirst([1, 2, 3]); // number
 
a.toUpperCase(); // OK — string のメソッド
b.toFixed(2); // OK — number のメソッド
b.toUpperCase(); // エラー — number に toUpperCase はない

1 つの関数で、型の安全性を保ったまま、どんな型の配列にも対応できるようになりました。


<T> の読み方

<T> は「型パラメータ」と呼ばれます。関数の (x: number) が「値の入れ物」であるのと同様に、<T> は「型の入れ物」です。

//       型パラメータ
//          ↓
function getFirst<T>(arr: T[]): T {
  return arr[0];
}

T という名前に特別な意味はありません。慣習的に使われているだけで、<Foo> でも <MyType> でも動きます。ただし、1 文字の慣例が広く使われています(後述)。


型推論と明示的な型引数

型推論に任せる(基本)

多くの場合、TypeScript は引数から型パラメータを推論できます。

function wrap<T>(value: T): { value: T } {
  return { value };
}
 
const a = wrap(42); // { value: number }
const b = wrap('hello'); // { value: string }

型引数を書かなくても、渡した値から T が自動的に決まります。推論が効く場合は省略するのが普通です。

明示的に型引数を渡す

引数から推論できない場合は、呼び出し側で型引数を明示します。

async function fetchApi<T>(endpoint: string): Promise<T> {
  const response = await fetch(endpoint);
  return response.json() as Promise<T>;
}
 
type User = { id: string; name: string; email: string };
 
// 引数は string なので、T を推論できない → 明示する
const user = await fetchApi<User>('/api/users/1');
user.name; // string(補完が効く)

複数の型パラメータ

型パラメータは複数持てます。

function makePair<A, B>(first: A, second: B): [A, B] {
  return [first, second];
}
 
const pair = makePair('age', 25); // [string, number]

もう少し実用的な例として、配列の変換関数を見てみます。

function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
  return arr.map(fn);
}
 
const lengths = map(['hello', 'world'], (s) => s.length);
// T = string, U = number と推論される

これは Array.prototype.map と同じシグネチャです。標準ライブラリの型定義もジェネリクスで書かれています。


制約(extends)

学習者学習者

<T> ってどんな型でもOKなんだよね?じゃあ value.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(123); // エラー — number に length はない

T extends { length: number } は「Tlength プロパティを持つ型でなければならない」という意味です。制約がないと value.length にアクセスした時点でコンパイルエラーになります。

keyof との組み合わせ

オブジェクトのキーを型安全に取得するパターンです。

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
 
const user = { name: '太郎', age: 30 };
getProperty(user, 'name'); // string
getProperty(user, 'age'); // number
getProperty(user, 'email'); // エラー — 'email' は存在しない

K extends keyof T により、key には T の実際のプロパティ名しか渡せません。タイポや存在しないキーを指定するとコンパイル時にエラーになります。


型エイリアス・インターフェースのジェネリクス

ジェネリクスは関数だけでなく、型エイリアスやインターフェースにも使えます(型エイリアスとインターフェースの基本は型エイリアスとインターフェースを参照)。

型エイリアス

type ApiResponse<T> = {
  data: T;
  status: number;
  message: string;
};
 
type User = { id: string; name: string };
type Product = { id: string; price: number };
 
type UserResponse = ApiResponse<User>; // data: User
type ProductResponse = ApiResponse<Product>; // data: Product

インターフェース

interface Repository<T> {
  findById(id: string): Promise<T | null>;
  findAll(): Promise<T[]>;
  save(item: T): Promise<void>;
}

as キャストとの違い

ジェネリクスを使わない場合、as でキャストする書き方になりがちです。

// as キャスト — 戻り値を「後から別の型として扱う」
const user = (await fetchApi('/api/users/1')) as User;
 
// 型引数 — 「この関数はこの型を返す」と宣言する
const user = await fetchApi<User>('/api/users/1');

どちらも実行時の挙動は同じですが、as は「型の上書き」なので、実際の値と無関係な型を指定してもコンパイルが通ってしまいます。

// as は全然違う型でも通る
const user = (await fetchApi('/api/users/1')) as number; // コンパイルOK、実行時に壊れる

型引数の方が、関数のシグネチャ(Promise<T> を返す)と一貫した形で型が決まるため、意図しない型の握りつぶしが起きにくくなります。

PCで作業をしている女性のイラスト

デフォルト型パラメータ

関数の引数にデフォルト値を設定できるように、型パラメータにもデフォルトを設定できます。

type ApiResponse<T = unknown> = {
  data: T;
  status: number;
};
 
// 型引数を省略すると T = unknown
const res: ApiResponse = { data: 'something', status: 200 };
 
// 明示すれば上書き
const userRes: ApiResponse<User> = { data: user, status: 200 };

ユーティリティ型と Pick

TypeScript の組み込みユーティリティ型(Pick, Omit, Partial など)はジェネリクスで実現されています。

Pick で必要なフィールドだけ抽出する

外部ライブラリが提供する大きな型から、実際に使うフィールドだけを取り出せます。

// ライブラリが提供する型(大量のプロパティ)
type LibraryItem = {
  id: string;
  title: string;
  description: string;
  createdAt: string;
  updatedAt: string;
  metadata: Record<string, unknown>;
  // ... 他にも多数
};
 
// 実際に使うフィールドだけを抽出
type ItemSummary = Pick<LibraryItem, 'id' | 'title'>;
// { id: string; title: string }

アプリ固有のフィールドを足したい場合は、交差型(&)で合成します。

type AppItem = Pick<LibraryItem, 'id' | 'title'> & {
  isFavorite: boolean;
};

自分で全プロパティの型を書き直すよりも、ライブラリの型定義が更新されたときに追従しやすくなります。

Pick 以外にも PartialOmitRequiredRecord など多くの組み込みユーティリティ型があります。これらはすべてジェネリクスで実装されており、ユーティリティ型の章でまとめて扱います。


React コンポーネントとジェネリクス

ジェネリクスは React でも頻繁に登場します。「どんな型のデータでも扱える、再利用可能なコンポーネントやフック」を、any に頼らず型安全に書けるのが強みです。

useState のジェネリクス

useState はジェネリック関数です。多くは初期値から型が推論されますが、初期値が null などで推論できないときは型引数を明示します。

// 初期値から推論される
const [count, setCount] = useState(0); // number
 
// 初期値が null なので明示する
const [user, setUser] = useState<User | null>(null);

ジェネリックなコンポーネント

「任意の型のリストを受け取って描画する」ような汎用コンポーネントは、型パラメータで書きます。

type ListProps<T> = {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
};
 
function List<T>({ items, renderItem }: ListProps<T>) {
  return (
    <ul>
      {items.map((item, i) => (
        <li key={i}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}
 
// 使う側。T は items から推論される
<List items={users} renderItem={(u) => u.name} />; // T = User
<List items={[1, 2, 3]} renderItem={(n) => n * 2} />; // T = number

renderItem の引数 item の型が、渡した items の要素型に自動で決まります。any を使わずに、あらゆる型のリストを 1 つのコンポーネントで安全に描画できます。

カスタムフックのジェネリクス

戻り値の型が引数に依存するカスタムフックでも活躍します。

function useLocalStorage<T>(key: string, initial: T) {
  const [value, setValue] = useState<T>(initial);
  // ...保存・読み込みのロジック
  return [value, setValue] as const;
}
 
const [token, setToken] = useLocalStorage('token', ''); // string
const [count, setCount] = useLocalStorage('count', 0); // number

React と TypeScript の組み合わせは、ReactとTypeScriptの章でさらに詳しく扱います。


よくある疑問

T じゃないとダメ?

ダメではありません。ただし以下の慣例が広く使われています。

名前由来使われる場面
TType最も一般的。単一の型パラメータ
U2 つ目の型パラメータ(T の次)
KKeyオブジェクトのキーを表す
VValueオブジェクトの値を表す
EElementコレクションの要素を表す

1 文字だと読みにくい場合は、意味のある名前を使うこともあります。

function fetchAndTransform<TRaw, TResult>(
  fetcher: () => Promise<TRaw>,
  transform: (raw: TRaw) => TResult,
): Promise<TResult> {
  return fetcher().then(transform);
}

いつジェネリクスを使うべき?

以下のどれかに当てはまるときに検討してください。

  • 型だけが違う同じ処理を、複数の型に対して書いている
  • 関数の戻り値の型が、引数の型に依存する
  • anyunknown を使っているが、本当は型を追跡したい
  • ライブラリやユーティリティ関数など、再利用性の高いコードを書いている

逆に、特定の 1 つの型しか扱わない関数にジェネリクスを使う必要はありません。

<T> はどこに書く?

// 関数宣言
function fn<T>(arg: T): T { ... }
 
// アロー関数
const fn = <T>(arg: T): T => { ... };
 
// TSX ファイルでのアロー関数(<T> が JSX タグと紛らわしいので extends をつける)
const fn = <T extends unknown>(arg: T): T => { ... };
 
// 型エイリアス
type Box<T> = { value: T };
 
// インターフェース
interface Box<T> { value: T }