🌟

シンプルな関数を例に TypeScript のジェネリック型の理解を深める

に公開

以下のようなジェネリック型が使われているコードを見てどう感じますか?

const pluck = <T, K extends keyof T>(records: T[], key: K): T[K][] => {
  return records.map((r) => r[key]);
};

開発現場での出現頻度は比較的少なくないですが、TypeScript 初心者からすると結構とっつきにくいかと思います。

  • ジェネリック型をいまいち使いこなせていない
  • オブジェクトの配列から特定のプロパティを抽出しするときなどに、any 型に頼ってしまっている

本記事では、そんな方に役立つようジェネリック型の使い方を解説します!

ジェネリック型の基礎

前提として使用するデータ型

まず、例で使用するブログ記事のデータ型を定義しておきます。

type Category = "tech" | "lifestyle" | "business";

interface BlogPost {
  title: string;
  author: string;
  publishedAt: Date;
  category: Category;
}

const blogPosts: BlogPost[] = [
  {
    title: "TypeScriptのジェネリック型入門",
    author: "田中太郎",
    publishedAt: new Date("2024-01-15"),
    category: "tech",
  },
  {
    title: "効率的なリモートワークのコツ",
    author: "佐藤花子",
    publishedAt: new Date("2024-02-10"),
    category: "lifestyle",
  },
];

とりあえず any を使った実装とその問題点

最初に、型を any とした実装から始めてみましょう。

const pluck = (records: any[], key: string): any[] => {
  return records.map((r) => r[key]);
};

この実装には以下のような問題があります。

  • any型を使用しているため、型安全性が失われており、TypeScript の恩恵を受けられていない
  • 戻り値の型が不明確で、IDE の補完も効かない
  • 存在しないプロパティを指定してもエラーにならない

つまり、問題だらけです!

ジェネリック型による段階的な改善

それではこのような関数に対しどのような型定義をすることが適切なのか段階的に改善して行きましょう!

ステップ 1: ジェネリック型の導入(失敗例)

次に、ジェネリック型を導入してみましょう。

const pluck = <T>(records: T[], key: string): any[] => {
  return records.map((r) => r[key]);
};

エラーが発生します。TypeScript はstring型のキーで任意のオブジェクトにアクセスすることを許可していません。
key として有効な値は、title author publishedAt categoryの 4 つのみであり、これは型安全性を保つための仕様です。

ステップ 2: keyof 演算子による型安全性の確保

この問題を解決するために、keyof演算子を使用します。

type K = keyof BlogPost;

keyof BlogPostは、BlogPostインターフェースのすべてのプロパティ名をユニオン型として取得します。

const pluck = <T>(records: T[], key: keyof T) => {
  return records.map((r) => r[key]);
};

これで型エラーは解消されました!
ですが、実際に推論させれている型を確認すると以下のようになっています。

const pluck: (records: T[], key: string | number | symbol): T[keyof T][]

全ての key(つまりプロパティ) を含んでおり、定義として広すぎます。

ステップ 3: 戻り値の型の明確化

プロパティを限定できるよう、戻り値の型も正確に指定しましょう。

const pluck = <T>(records: T[], key: keyof T): T[keyof T][] => {
  return records.map((r) => r[key]);
};

しかし、この実装では以下のような問題が発生します。

const publishedDates = pluck(blogPosts, "publishedAt");

Date[]を期待していたのに、(string | Date)[]になってしまいました。これはT[keyof T]が全てのプロパティの値の型のユニオン型になってしまうためです。

解決策:K extends keyof T

2 つ目のジェネリック型パラメータの導入

解決策として、2 つ目のジェネリック型パラメータKの出番です。

const pluck = <T, K extends keyof T>(records: T[], key: K): T[K][] => {
  return records.map((r) => r[key]);
};

型安全で正確な動作の実現

この実装により、以下のような型安全で正確な動作が実現されます。

const publishedDates = pluck(blogPosts, "publishedAt");

// 存在しないプロパティを指定するとコンパイルエラーとなる!
const badProperty = pluck(blogPosts, "createdBy");

まとめ

シンプルな関数の例を通して、TypeScript のジェネリック型の重要な概念を学びました。

  • ジェネリック型の基本: <T>で型パラメータを定義し、関数をより汎用的にできる
  • keyof 演算子: オブジェクトの型からプロパティ名のユニオン型を取得
  • extends 制約: K extends keyof Tで型パラメータに制約を加える
  • インデックスアクセス型: T[K]で特定のプロパティの型を取得

これによって、型安全で使いやすいユーティリティ関数を作成できました。

Discussion