ジェネリクスと、既存コードの型付け
レッスン7:ジェネリクスと、既存コードの型付け
このレッスンで学ぶこと
- ジェネリクスの基礎を理解し、型を引数のように扱える
- よく使う組み込みの型を知る
- 既存の JavaScript コードに少しずつ型を足していく手順を身につける
- 型チェックを自動実行につなぐ接続点を理解する
レッスン6 では、class と モジュールを使って、大きくなったコードを意味のある単位に分ける方法を学びました。ここまでのレッスンで、string[] や Todo[] のように、特定の型だけを扱う関数やクラスを何度も書いてきました。このレッスンでは、「どんな型が来ても対応できる、けれど型の安全性は保ったまま」というコードを書くための仕組み、ジェネリクスを学びます。後半では、視点を新しいコードから既存のコードに移し、すでにある JavaScript のコードへ少しずつ型を足していく現実的な手順を扱います。
ジェネリクスとは——型を引数のように扱う
次の関数を見てください。配列の最初の要素を返すだけの、単純な関数です。
function getFirst(items: number[]): number {
return items[0];
}
function getFirstString(items: string[]): string {
return items[0];
}
getFirst は number[] 専用、getFirstString は string[] 専用です。やっていることはまったく同じなのに、型が違うだけで別々の関数を用意しなければなりません。この重複を解消するのがジェネリクスです。
function getFirst<T>(items: T[]): T {
return items[0];
}
const firstNumber = getFirst([85, 62, 78]);
const firstString = getFirst(["りんご", "みかん"]);
<T> の部分がジェネリクスの宣言です。T は「今はまだ決まっていない、呼び出されたときに決まる型」を表す仮の名前で、関数の引数のように振る舞います。getFirst([85, 62, 78]) のように数値の配列を渡すと、T は自動的に number に決まり、戻り値の型も number になります。文字列の配列を渡せば、T は string になります。
console.log(firstNumber.toFixed(1)); // number として扱える
console.log(firstString.toUpperCase()); // string として扱える
any を使えば同じような柔軟さを再現できそうに見えますが、大きな違いがあります。
function getFirstAny(items: any[]): any {
return items[0];
}
const result = getFirstAny([85, 62, 78]);
result.toUpperCase(); // any なのでコンパイルエラーにならない(実行時にエラーになる)
any を使った場合、戻り値の型情報が失われてしまうため、数値に対して文字列用のメソッドを呼び出すような間違いも検出されません。ジェネリクスを使った getFirst は、渡した配列の型に応じて戻り値の型も正しく決まるため、firstNumber.toUpperCase() のような間違った呼び出しはコンパイルの段階で止められます。
💡 ポイント ジェネリクスは「型を決めずに書く」ための仕組みではなく、「型を後から決める」ための仕組みです。
anyが型のチェックを放棄するのに対し、ジェネリクスは呼び出された時点の具体的な型に基づいて、チェックを最後まで続けます。
ジェネリクスを複数使う
型引数は 1 つに限らず、必要な数だけ使えます。
function pair<A, B>(first: A, second: B): [A, B] {
return [first, second];
}
const result = pair("森田 彩花", 30);
console.log(result[0].toUpperCase()); // A は string
console.log(result[1].toFixed(1)); // B は number
pair は、2 つの異なる型の値を受け取り、その組み合わせを返す関数です。A と B という 2 つの型引数を使うことで、「1 つ目と 2 つ目の引数が別の型であってもよいが、それぞれの型情報はきちんと保持する」という動きを実現しています。
ジェネリクスに制約を付ける
型引数 T は、そのままでは「どんな型でも受け付ける」状態になっています。場面によっては、「配列や文字列のように、length プロパティを持つ型だけを受け付けたい」というように、範囲を絞りたいことがあります。これには extends を使った制約を付けます。
function describeLength<T extends { length: number }>(value: T): string {
return `長さ:${value.length}`;
}
console.log(describeLength("こんにちは")); // string は length を持つ
console.log(describeLength([1, 2, 3])); // 配列も length を持つ
console.log(describeLength(100)); // エラー:number は length を持たない
<T extends { length: number }> は、「T は、少なくとも length という数値のプロパティを持つ形でなければならない」という制約です。レッスン5 で学んだ extends はインターフェースの拡張に使うキーワードでしたが、ジェネリクスの文脈では「この型引数が満たすべき最低限の形」を指定する働きをします。制約を付けることで、T の中身が完全に未知のままでも、value.length のように、制約で保証された部分だけは安心して使えるようになります。
ジェネリックなインターフェース
ジェネリクスは、関数だけでなくインターフェースにも使えます。
interface Box<T> {
content: T;
}
const numberBox: Box<number> = { content: 100 };
const stringBox: Box<string> = { content: "森田 彩花" };
Box<T> は、「中に何かを 1 つ入れる箱」という形を表すインターフェースです。Box<number> と書けば content が number の箱、Box<string> と書けば content が string の箱になります。レッスン5 で作った Todo インターフェースも、複数の種類のデータを扱いたい場面では、こうしたジェネリックな形に発展させられます。
よく使う組み込みの型——Partial・Readonly・Record
TypeScript には、既存の型を加工して新しい型を作るための、組み込みの型がいくつか用意されています。ここでは代表的な 3 つを紹介します。
Partial<T> は、インターフェースのすべてのプロパティを省略可能に変換します。
interface User {
name: string;
age: number;
}
function updateUser(id: number, changes: Partial<User>) {
console.log(`ユーザー${id}を更新:`, changes);
}
updateUser(1, { age: 31 }); // name を省略できる
プロフィールの編集フォームのように、「一部のプロパティだけを更新したい」場面で、Partial<User> を使うと、User のすべてのプロパティをその都度省略可能な形で書き直す手間が省けます。
Readonly<T> は、レッスン5 で学んだ readonly を、インターフェースのすべてのプロパティにまとめて適用します。
interface Config {
apiUrl: string;
timeout: number;
}
const config: Readonly<Config> = {
apiUrl: "https://example.com/api",
timeout: 3000
};
config.timeout = 5000; // エラー:Readonly のため変更できない
設定値のように、アプリケーションの起動後に変更されては困る値をまとめて扱うときに向いています。
Record<K, V> は、キーの型と値の型を指定して、オブジェクト全体の形を作ります。
const scoreByName: Record<string, number> = {
"森田": 85,
"佐藤": 62,
"田中": 78
};
console.log(scoreByName["森田"]); // 85
Record<string, number> は、「キーが string、値が number であるオブジェクト」を表します。名簿や集計結果のように、決まったプロパティ名を持たず、キーと値の組が並ぶだけのデータを扱うときに使います。
📝 補足
Partial・Readonly・Recordは、TypeScript にあらかじめ組み込まれている「ユーティリティ型」と呼ばれる仲間の一部です。ほかにも同じ考え方で作られたユーティリティ型がいくつかありますが、本コースではまず、実務でよく見かけるこの 3 つを覚えておけば十分です。
既存のJavaScriptを少しずつTypeScriptにしていく
ここからは、視点を新しく書くコードから、すでにある JavaScript のコードに移します。実務では、真っ白な状態から TypeScript でコードを書き始めるよりも、すでに動いている JavaScript のコードに、少しずつ型を足していく場面のほうが多くあります。
移行の手順として、次の 4 つの段階を踏むのが現実的です。
- ファイルの拡張子を
.jsから.tsに変える:レッスン1 で確認したとおり、TypeScript は JavaScript の上位互換なので、多くの場合はこの時点でまだエラーは出ません - コンパイルエラーが出た箇所から順に直す:拡張子を変えた直後から、暗黙のうちに
anyとして扱われていた値や、明らかな型の不一致がエラーとして表示され始めます - 関数の入り口(引数と戻り値)から型を足す:レッスン4 で学んだとおり、関数の境界に型を付けると、その関数を呼び出す先すべてに影響が波及します
- データの形(インターフェース)を整理する:関数まわりが落ち着いたら、レッスン5 で学んだインターフェースでデータの形を整理し、ファイルをまたいで共有します
// 移行前(calculateTax.js に近い状態)
function calculateTax(price, taxRate) {
return price + price * taxRate;
}
// 移行後(calculateTax.ts)
function calculateTax(price: number, taxRate: number): number {
return price + price * taxRate;
}
1 つの関数に型を足すだけであれば、変更はごくわずかです。この小さな変更を、ファイルの中の関数 1 つずつ、あるいはファイル 1 つずつに対して繰り返していくのが、既存コードへの型の導入の基本的な進め方です。
⚠️ 注意 一度にすべてのファイルを書き換えようとすると、大量のエラーに埋もれて、どこから手を付ければよいかわからなくなりがちです。中核メッセージの 1 つである「型を足すのは一度に全部ではなく、端から少しずつでよい」を、まさにこの場面で実践します。
anyを減らす順序——事故が起きやすい場所から
型を足す順番に迷ったときは、「事故が起きやすい場所」から手を付けるのが効率的です。優先順位の目安は次のとおりです。
- 外部から来るデータの境界:フォームの入力値、サーバーから返ってきたデータなど、レッスン2 で学んだ
unknownを使って安全に受け取るべき場所 - 複数人が触る関数の入り口:チームで共有している関数ほど、引数の形を誤解されやすく、型による説明の効果が大きい
- 何度も繰り返し使われるデータの形:レッスン5 のインターフェースで一元管理すると、変更の影響範囲が把握しやすくなる
逆に、一度しか使わない使い捨てのコードや、今後書き直す予定が決まっている部分は、優先順位を下げてよい場所です。すべてを完璧に型付けすることよりも、事故が起きたときの被害が大きい場所から手を付けることのほうが、限られた時間の中では効果的です。
型付けが行き届いていない部分が残っていても構いません。レッスン2 で学んだとおり、any はどうしても避けられない場面で最後の手段として使い、代わりに unknown を選べないかを先に検討する、という優先順位を持っておくと、コード全体の安全性を少しずつ底上げしていけます。100%の型カバレッジを最初から目指す必要はなく、「昨日より今日のほうが、型で守られている範囲が広い」という状態を積み重ねる考え方のほうが、長期的には現実的です。
この判断の流れを図にすると、次のようになります。
flowchart TD
A["既存のJSファイル"] --> B{"外部データの境界か?"}
B -->|Yes| C["unknownで受け取り、絞り込んでから使う"]
B -->|No| D{"複数人が触る関数か?"}
D -->|Yes| E["引数・戻り値に型を付ける"]
D -->|No| F["優先順位を下げ、後回しにする"]
この図は、限られた時間の中でどこから型を足すべきかを判断する流れを表しています。すべての箇所に同じ熱量で取り組むのではなく、影響範囲の大きい場所から順に手を付けることが、挫折せずに移行を続けるコツです。
⚠️ 注意 「全部のエラーを消してから使い始めよう」と考えると、途中で止まってしまいがちです。TypeScript は、コンパイルエラーが残っていても、設定次第では動くコードを出力できます。まずは一部のファイルだけ
.tsに変え、エラーが出ている行を認識しながら少しずつ育てていくくらいの気持ちのほうが、結果的に長続きします。
型チェックを自動実行につなぐ
ここまでは、npx tsc を手元で実行して型チェックする流れを扱ってきました。実務では、コードをリポジトリに送るたびに、型チェックが自動的に実行されるように設定するのが一般的です。バージョン管理関連の入門コースで扱う CI(継続的インテグレーション)の仕組みの中に、npx tsc --noEmit(JavaScript への変換はせず、型チェックだけを行うコマンド)を組み込んでおくことで、「型の間違いが残ったままのコードが、うっかり公開されてしまう」事故を防げます。
CI の仕組みそのものの詳しい説明はバージョン管理関連の入門コースに譲りますが、「TypeScript の型チェックは、手元のエディタだけでなく、チーム全体の自動チェックの一部としても機能する」という接続点は、本コースの範囲としてここで押さえておきます。ESLint(コードの書き方の間違いを検出する道具)や Prettier(コードの見た目を自動的に整える道具)といった固有名詞も、TypeScript の型チェックと合わせてよく使われますが、これらの仕組み自体の解説も、本コースの範囲外とします。
型チェックを自動実行の一部に組み込んでおく利点は、「自分のエディタでは気づかなかった間違いを、ほかの人が気づける」という点にあります。手元の環境では見落としていた型の不一致も、コードをリポジトリに送った時点で自動的に検出されれば、動かないコードが公開される前に食い止められます。個人で書くコードであっても、この自動チェックの仕組みを用意しておくと、数か月後に見返したときの安心材料になります。
実習:JavaScriptの関数をTypeScriptへ移行する
「JavaScript 入門」で書いたような、値引き計算を行う関数を例に、移行の手順を一通り実践してみましょう。
// 移行前(discount.js を想定した状態)
// function applyDiscount(items, rate) {
// return items.map(item => {
// return { name: item.name, price: item.price * (1 - rate) };
// });
// }
// 移行後(discount.ts)
interface Item {
name: string;
price: number;
}
function applyDiscount(items: Item[], rate: number): Item[] {
return items.map((item) => {
return { name: item.name, price: item.price * (1 - rate) };
});
}
const items: Item[] = [
{ name: "ノート", price: 200 },
{ name: "ペン", price: 100 }
];
console.log(applyDiscount(items, 0.1));
まず Item インターフェースでデータの形を決め、関数の引数と戻り値をその形で固定しました。map に渡すコールバック関数の item は、レッスン3 で学んだコンテキストからの型推論によって、型注釈を書かなくても自動的に Item として扱われます。もとの JavaScript のコードと見比べると、変更しているのは関数の宣言部分とデータの形の定義だけで、処理の中身そのものはほとんど変わっていません。
この規模の関数であれば移行は数分で終わりますが、実際のプロジェクトでは、こうした小さな移行を、ファイルの数だけ積み重ねていくことになります。1 つのファイルを移行し終えるたびにコンパイルを実行し、エラーが出なくなったことを確認してから次のファイルに進む、という地道な繰り返しが、結果として一番早く安全にゴールへたどり着く道です。焦って複数のファイルを同時に書き換えると、どのファイルのどの変更が原因でエラーが増えたのかがわかりにくくなり、かえって時間がかかることがあります。
まとめ
このレッスンでは、以下のことを学びました。
- ジェネリクス(
<T>)を使うと、型を引数のように扱い、複数の型に対応する関数やインターフェースを、型の安全性を保ったまま 1 つにまとめられる Partial・Readonly・Recordなど、既存の型を加工する組み込みのユーティリティ型がある- 既存の JavaScript コードへの移行は、拡張子の変更→エラーの修正→関数の境界→データの形の整理、という順序で進めると現実的
- 型を足す優先順位は、外部データの境界や複数人が触る関数など、事故が起きやすい場所から
- 型チェックは、手元での実行だけでなく、CI に組み込んで自動化できる
次のレッスンでは、エラーメッセージの読み方、型定義ファイルの存在、そして「型を書きすぎない」という判断について学び、コース全体の締めくくりとして今後の学習の方向を確認します。
確認クイズ
このレッスンの理解度をチェックしましょう。