JavaScript / TypeScript公開 約8分

TypeScript ジェネリクスで検索結果の型を保つ:制約と戻り値の関係

共通の検索関数へまとめたら、本のページ数を読めなくなった場合に。型引数で入力と出力を結び、idを読むための制約を加えます。該当なしと不適切な型指定を、型検査と実行で別々に確かめます。

この記事の目次
  1. 共通なのは検索方法で、結果の項目ではない
  2. 一つの関数へ、本と部屋を順番に渡す
  3. 型を狭めすぎる失敗と、代用品を作る失敗
  4. 型引数は、戻してほしい型を注文する欄ではない
  5. 新しい項目が、関数を直さず結果へ残るか

本と部屋の一覧に、同じID検索を二度書いている。共通関数へ移すと検索自体は動くのに、結果からページ数や座席数を読めなくなった。共通部分だけの型へそろえたことで、呼び出し側が持っていた情報を戻り値に残せていない状態です。

配列のfindとTypeScriptの型注釈が読める人向けに、架空の資料施設のデータを使います。Node.js 24系、TypeScript 5.9.3で試す、通信のない教材です。見つからない場合の基本はfindの結果を確認する記事を前提にし、今回は関数の共通化で型の対応を失わない方法を扱います。

共通なのは検索方法で、結果の項目ではない

本にはid・title・pages、部屋にはid・name・seatsがあります。検索に必要なのはidだけですが、呼び出した後は本のpagesや部屋のseatsを使いたい。この二つの要求を一つの固定型で表すと、情報を落とすか、使わない項目まで要求することになります。

たとえば戻り値を{ id: string } | undefinedにすると、見つかった値にpagesが存在していても、型にはその情報がありません。anyにすれば項目を読めますが、部屋の結果へpagesを読んでも型検査で見つけられなくなります。

型引数Tは、呼び出しごとの型を受け取る名前です。readonly T[]を引数に、T | undefinedを戻り値に使い、両者を結びます。公式Handbookのジェネリクスで説明される、型を受け渡す仕組みです。

ただし無条件のTでは、関数内でidを読めません。数値や文字列もTになり得るからです。T extends { id: string }と書き、Tには少なくとも文字列のidが必要だと制約を加えます。extendsは、この場所ではオブジェクトを作り替えたりクラスを継承させたりする命令ではありません。

呼び出しの材料 推論されるT 戻り値
本の配列 本の全項目を持つ型 その本の型、またはundefined
部屋の配列 部屋の全項目を持つ型 その部屋の型、またはundefined
idのない配列 制約に合わない 呼び出しを型検査で拒否

一つの関数へ、本と部屋を順番に渡す

空の練習フォルダでnpm init -y、npm install --save-dev --save-exact typescript@5.9.3を実行します。次をlookup.tsへ保存してください。package.jsonのtypeは変更せず、CommonJS形式へ変換する構成です。

export function findById<T extends { id: string }>(
  items: readonly T[],
  id: string
): T | undefined {
  return items.find(item => item.id === id);
}

export const books = [
  { id: 'b1', title: '町の観察ノート', pages: 48 },
  { id: 'b2', title: '水辺の記録', pages: 72 }
];
const rooms = [{ id: 'r1', name: '閲覧室', seats: 12 }];

const book = findById(books, 'b2');
const room = findById(rooms, 'r1');
if (book !== undefined) console.log(`${book.title}:${book.pages}ページ`);
if (room !== undefined) console.log(`${room.name}:${room.seats}席`);
console.log(findById(books, 'missing') === undefined);
console.log(findById(books.slice(0, 0), 'b1') === undefined);
console.log(findById(books, 'b1') === books[0]);

実行は次の二段階です。型検査が成功した回だけ、生成されたJavaScriptを実行します。

npx tsc lookup.ts --strict --target ES2022 --module commonjs --noEmitOnError --outDir out
node out/lookup.js

期待する出力は「水辺の記録:72ページ」「閲覧室:12席」、続いてtrueが三行です。呼出しに型引数を明記しなくても、配列からTを推論できます。関数本体の検索手順は二種類のデータで同じですが、結果を使える項目は呼び出しごとに変わります。

最後のtrueは、元の要素と同じオブジェクトを返したことを示します。ジェネリクスはコピーを作る機能ではありません。引数のreadonlyはこの関数から配列を並べ替える等の操作を制限しますが、返ったオブジェクトを深く凍結する指定でもありません。

関数や配列の実行順に不安があれば、JavaScriptの無料レッスンで値の流れを確認できます。ここではその流れへ、入力と出力の型の対応を重ねています。

型を狭めすぎる失敗と、代用品を作る失敗

第一の失敗は、完成例の戻り値だけを{ id: string } | undefinedへ変えることです。呼び出した側でbook.titleやbook.pagesを読めず、存在しないプロパティという診断になります。実データが欠けたのではなく、戻り値の宣言から情報が落ちたのが原因です。T | undefinedへ戻し、二種類の固有項目が読めることを再確認します。

第二の失敗は、見つからない場合に{ id }を作ってTとして返そうとすることです。idという最低条件は満たしていても、本ならpages、部屋ならseatsが不足します。戻り値をTへ変えて次の一行を使うと、任意のTを満たすとはいえないという型エラーになります。

return items.find(item => item.id === id) ?? { id };

これは完成関数の本体へ一時的に入れる失敗例です。as Tで押し通すと、存在しない本なのに本として扱えてしまいます。修正はundefinedを戻り値へ残し、呼び出し側で該当なしを表示することです。公式の関数の説明にある、制約を満たす値と、呼び出しごとの具体的な型を満たす値の違いに当たります。

修正後はmissingと空配列が両方trueになるかを再実行します。型エラーが消えたことだけで終えず、代用品を返していないことを値でも確認してください。

型引数は、戻してほしい型を注文する欄ではない

明示した型引数は、実データを変換しません。下記を別のrejected.tsへ保存し、型検査だけを行います。完成コードへ混ぜない例です。

import { books, findById } from './lookup';

findById<{ id: string; seats: number }>(books, 'b1');
const result = findById(books, 'b1');
console.log(result.pages);
npx tsc rejected.ts --strict --target ES2022 --module commonjs --noEmit

一つ目は本のデータにseatsがないため、引数が合わないという診断になります。二つ目はresultがundefinedかもしれないという診断です。IDがb1でも、findByIdの型はその文字列が必ず存在するとは約束していません。後者はifで分岐してからpagesを読む形へ直します。

外部JSONの正しさもTだけでは確かめられません。unknownで受信データを検査する記事で扱う実行時の検証を、型引数の指定で代用しないでください。また、一種類のデータしか扱わず共通化の必要もなければ、具体的な本の型を受け取る関数で十分です。

新しい項目が、関数を直さず結果へ残るか

15分程度の練習として、本の各要素へedition: 2を追加し、見つかったbookのeditionも出力します。検索関数は変更せず、undefinedを除いた後で2を読めれば、型の対応が保たれています。idの型を数値へ変えた配列は今回の制約に合わず、拒否されるのが解答です。

完成の判定は、正常な二件では固有の項目を読めること、該当なしと空配列ではundefinedになること、誤った型引数は検査を通らないことの三つです。共通化した行数より、呼び出し側が何を安全に読めるままかを記録してください。

TRY A LESSON

記事の内容を、課題で確かめる

JavaScriptでフォームを作る練習 →

JavaScriptの学習内容 →

記事についてのお問い合わせ

記載内容へのご質問や訂正のご連絡は、hello@junkbranding.comまでお願いします。

← 記事一覧に戻る