API から返ってくる JSON や、ユーザーが入力したフォームの値のように「どんなキーが来るか事前に分からないオブジェクト」を型付けしたいことがあります。プロパティ名を1つずつ書き並べるわけにいかないこうしたケースで役立つのが、TypeScript のインデックスシグネチャ({ [key: string]: T })です。この記事では、インデックスシグネチャの基本構文から、string と number のキーの扱い、既存プロパティと併用するときの制約、readonly 指定、そして Record 型との使い分けや実際につまずきやすい点まで、初心者〜中級者向けに解説します。
目次
インデックスシグネチャとは
インデックスシグネチャは、オブジェクトの「キーの型」と「値の型」をまとめて宣言する書き方です。{ [key: string]: T } のように、角括弧の中にキーを表す仮の名前とその型を、コロンの後ろに値の型を書きます。これは「このオブジェクトは、任意の文字列キーに対して T 型の値を持つ」という意味になります。プロパティ名を1つずつ列挙するのではなく、「どんなキーでも値はこの型」とまとめて指定できるのが特徴です。
// 任意の文字列キーに対して number 型の値を持つオブジェクト
type Scores = {
[key: string]: number;
};
const scores: Scores = {
math: 80,
english: 92,
science: 75,
};
// 事前に宣言していないキーでも代入・参照できる
scores.history = 68;
console.log(scores.math); // 80
角括弧内の key という名前は仮のものなので、[prop: string] でも [k: string] でも構いません。あくまでキーの型を示すためのラベルであり、名前自体に意味はありません。上の Scores 型では、どんな文字列キーに対しても値が number であることだけを保証しており、math や english といった具体的なキー名は型の側では決めていません。
どんなキーが来るか分からないオブジェクトに使う
インデックスシグネチャが最も活きるのは、キーの一覧を事前に確定できない場面です。たとえば、単語ごとの出現回数を数える集計や、ID をキーにしたデータの辞書など、実行してみるまでどんなキーが入るか分からないオブジェクトが該当します。こうした用途では、キーを1つずつ型に書き出すことができないため、インデックスシグネチャでまとめて型付けします。
// 単語をキー、出現回数を値にした集計オブジェクト
const wordCount: { [word: string]: number } = {};
const words = ["apple", "banana", "apple", "cherry", "banana", "apple"];
for (const word of words) {
// まだ登録されていないキーは 0 として扱って加算する
wordCount[word] = (wordCount[word] ?? 0) + 1;
}
console.log(wordCount); // { apple: 3, banana: 2, cherry: 1 }
この例では、配列に何が入っているか次第でキーが決まります。apple や banana といったキーをあらかじめ型に書くことはできないため、{ [word: string]: number } という形で「文字列キーなら値は数値」と宣言しています。これにより、どんな単語がキーになっても型エラーにならず、集計処理を素直に書けます。
string キーと number キー
インデックスシグネチャのキーには string のほかに number も指定できます。配列のように数値インデックスでアクセスするオブジェクトを型付けしたいときに使います。ただし、ここには JavaScript 由来の重要な仕様があります。JavaScript ではオブジェクトのキーは内部的にすべて文字列(またはシンボル)として扱われるため、number キーで指定した値も、実際には文字列に変換されてアクセスされます。
// number キーのインデックスシグネチャ
type NumberDict = {
[index: number]: string;
};
const items: NumberDict = {
0: "りんご",
1: "みかん",
2: "ぶどう",
};
console.log(items[1]); // "みかん"
この「number キーは内部的に string に変換される」という仕様のため、TypeScript には次の制約があります。string と number の両方のインデックスシグネチャを同じオブジェクトに持たせる場合、number キーの値の型は、string キーの値の型に代入可能でなければならない、というものです。number でアクセスしても最終的には string キーとしてアクセスされるので、両者の型が矛盾しないことが求められます。
// OK:number の値の型(string)が string の値の型(string | number)に代入可能
type Ok = {
[key: string]: string | number;
[index: number]: string;
};
// エラー:number の値の型(number)が string の値の型(string)に代入できない
type Ng = {
[key: string]: string;
[index: number]: number; // 'number' index type 'number' is not assignable to
// 'string' index type 'string'.
};
宣言済みプロパティと併用するときの制約
インデックスシグネチャは、名前を明示した通常のプロパティと同じ型の中で併用できます。「必ずある特定のプロパティ」と「それ以外の任意のキー」を1つの型で表したいときに便利です。ただしこのときにも制約があります。個別に宣言したプロパティの型は、インデックスシグネチャの値の型に代入可能でなければならないという決まりです。任意のキーでアクセスしたときの型と、名前付きプロパティの型が食い違わないようにするためのルールです。
// OK:name の型(string)がインデックスシグネチャの型(string | number)に代入可能
type Profile = {
name: string;
age: number;
[key: string]: string | number;
};
const profile: Profile = {
name: "Taro",
age: 30,
nickname: "Taro-chan", // 任意の追加キーも OK(string | number なら)
};
// エラー:宣言済みプロパティの型がインデックスシグネチャの型に合わない
type Invalid = {
id: boolean; // boolean は string | number に代入できない
[key: string]: string | number;
};
Profile 型では、name(string)と age(number)がどちらもインデックスシグネチャの string | number に含まれるため問題ありません。一方 Invalid 型では、id の boolean がインデックスシグネチャの string | number に含まれないためエラーになります。名前付きプロパティも「任意のキーでアクセスされうる対象」の一部なので、値の型が矛盾してはいけない、と考えると理解しやすいでしょう。
readonly で書き換えを禁止する
インデックスシグネチャの前に readonly を付けると、そのオブジェクトのプロパティを後から書き換えられないようにできます。初期化時に値を設定したあとは変更したくない、参照専用のデータを表したいといった場合に使います。readonly を付けたキーへ代入しようとすると、コンパイル時にエラーになります。
type ReadonlyDict = {
readonly [key: string]: number;
};
const rates: ReadonlyDict = {
usd: 150,
eur: 160,
};
console.log(rates.usd); // 150(参照は OK)
rates.usd = 155; // エラー:Index signature in type 'ReadonlyDict'
// only permits reading.
readonly による制限はコンパイル時の型チェックによるものです。値の読み取りは通常どおり行えますが、代入は型エラーとして検出されます。誤って書き換えてしまうバグを、実行前に防ぐのに役立ちます。
Record 型との違いと使い分け
「キーと値の型を指定したオブジェクト」を作る方法として、標準ライブラリの Record<K, T> ユーティリティ型もよく使われます。Record は内部的にはマップ型で定義されており、Record<string, number> は { [key: string]: number } とほぼ同じ意味になります。では両者をどう使い分ければよいのでしょうか。ポイントはキーの範囲が有限か無限かです。
| 観点 | インデックスシグネチャ | Record<K, T> |
|---|---|---|
| 向いているキー | 事前に確定できない無限のキー | あらかじめ決まった有限のキー |
| キーの型に指定できるもの | string / number / symbol(テンプレートリテラル型も可) | 任意の型(リテラルのユニオンなども可) |
| キーを限定した書き方 | そのままでは限定しにくい | Record<"a" | "b", T> のように限定しやすい |
| 典型的な用途 | 集計・辞書・任意キーのマップ | 決まったキーの設定・対応表 |
キーの候補が "success" | "warning" | "error" のように有限で分かっているなら、Record<"success" | "warning" | "error", string> のようにキーを限定できる Record が適しています。この書き方なら、必要なキーがそろっているかまで型で検査できます。反対に、単語の集計や任意の ID をキーにする辞書のように、どんなキーが来るか分からない無限のケースでは、インデックスシグネチャが素直です。
// キーが有限で分かっている → Record が向く
type StatusColor = Record<"success" | "warning" | "error", string>;
const colors: StatusColor = {
success: "#198754",
warning: "#ffc107",
error: "#dc3545",
// キーが足りない・余分だとエラーになる
};
// キーが無限で分からない → インデックスシグネチャが向く
type Cache = { [key: string]: number };
const cache: Cache = {};
cache["user_1024"] = 1; // どんな ID でも受け付けられる
存在しないキーへのアクセスに注意する
インデックスシグネチャを使ううえで最も注意したいのが、存在しないキーにアクセスしても型エラーにならない点です。{ [key: string]: T } は「任意の文字列キーに対して T がある」と型付けするため、実際には未定義のキーであっても、型のうえでは T として扱われます。その結果、実行時には undefined が返るのに、型システムはそれを見逃してしまいます。
type Scores = { [key: string]: number };
const scores: Scores = { math: 80 };
// english は存在しないが、型上は number として扱われる
const english = scores.english; // 型は number(実際は undefined)
// 型エラーにならないので、この計算も通ってしまう
console.log(english * 2); // 実行時は NaN(undefined * 2)
この落とし穴を型で防ぎたい場合は、tsconfig.json の noUncheckedIndexedAccess オプションを有効にします。このオプションを true にすると、インデックスシグネチャ経由でアクセスした値の型に自動的に undefined が加わり、値がないかもしれないことを型で表現できます。結果として、undefined チェックを挟まないと使えなくなり、うっかりミスをコンパイル時に検出できます。
// tsconfig.json で "noUncheckedIndexedAccess": true を有効にした場合
type Scores = { [key: string]: number };
const scores: Scores = { math: 80 };
const english = scores.english; // 型は number | undefined になる
// そのまま使うとエラー:'english' is possibly 'undefined'.
// console.log(english * 2);
// undefined チェックを挟めば安全に使える
if (english !== undefined) {
console.log(english * 2);
}
noUncheckedIndexedAccess はプロジェクト全体の型の厳しさに影響するため、途中から有効にすると多くの箇所で undefined チェックが必要になることがあります。ただ、インデックスシグネチャで生じがちな「存在しないキーを気づかず使う」バグを型で防げるため、任意キーのオブジェクトを多用するプロジェクトでは有効化を検討する価値があります。
まとめ
インデックスシグネチャ({ [key: string]: T })は、どんなキーが来るか事前に分からないオブジェクトを型付けするための書き方です。キーには string と number を指定でき、number キーは内部的に string に変換されるため、両者を併用する際は値の型の代入可能性に注意が必要です。名前付きプロパティと併用するときは、そのプロパティの型がインデックスシグネチャの値の型に代入可能でなければなりません。書き換えを防ぎたいときは readonly を付けられます。キーが有限なら Record、無限ならインデックスシグネチャ、と使い分けるのが基本です。そして最大の注意点は、存在しないキーでも型エラーにならず undefined になりうること。これを型で防ぎたいときは noUncheckedIndexedAccess を有効にしましょう。