インデックスシグネチャとは、{ [key: string]: number } のように「キーの型」と「値の型」だけを決めて、キーの名前を決めないオブジェクト型の書き方です。 集計結果や辞書のように、キーが実行時に決まるオブジェクトに使います。Record<string, number> と書いた型と同じものです。

この記事は [key: string] の書き方そのものに絞り、キーに書ける型、固定プロパティとの併用、存在しないキーを読んだときの型、2つのコンパイラオプション、readonly、interface と type の違いを、tsc のエラー全文つきで確かめます。

確認環境:Ubuntu 24.04 / Node.js 22.22.2 / TypeScript 7.0.2(npx tsc --version の表示は Version 7.0.2)。エラー文は、ことわりのない限り npx tsc --noEmit --strict の出力です。

インデックスシグネチャの早見表

キーに書ける型

キーの型 書き方の例 書けるか エラー
string { [key: string]: number } 書ける なし
number { [index: number]: string } 書ける なし
symbol { [key: symbol]: boolean } 書ける なし
テンプレートリテラル型(パターン) { [key: `data-${string}`]: string } 書ける なし
上の型どうしのユニオン { [key: string | number]: unknown } 書ける なし
string の別名(type Id = string) { [key: Id]: number } 書ける なし
リテラルのユニオン { [key: "a" | "b"]: number } 書けない TS1337
リテラル1つ { [key: "a"]: number } 書けない TS1337
型引数 { [key: K]: number } 書けない TS1337
enum { [key: Color]: number } 書けない TS1337
boolean・bigint { [key: boolean]: number } 書けない TS1268
省略可能の印つき { [key?: string]: number } 書けない TS1019

やりたいことから引く

やりたいこと 書き方
キーが実行時に決まる辞書 { [key: string]: V } または Record<string, V>
キーが決まった数種類 Record<"a" | "b", V> または { [K in "a" | "b"]: V }
一部は固定、残りは自由 索引の値の型を、固定プロパティの型をすべて含む型にする
存在しないキーを読んだら undefined 込みにしたい noUncheckedIndexedAccess を有効にする
索引由来のキーをドットで読めないようにしたい noPropertyAccessFromIndexSignature を有効にする
書き換えを止めたい { readonly [key: string]: V }

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「TypeScript インデックスシグネチャの使い方|Recordとの違い」

基本の書き方と読み方

type Stock = { [itemName: string]: number };
//             ~~~~~~~~  ~~~~~~   ~~~~~~
//             名前(任意) キーの型  値の型

const stock: Stock = { pen: 12, note: 5 };
stock["eraser"] = 3;      // 新しいキーを足せる
部分 意味
itemName 説明用の名前。key・index など自由に付けられ、型の動きには影響しない
string キーの型
number そのキーで読み書きする値の型

interface でも同じ書き方ができます。

interface Stock {
  [itemName: string]: number;
}

キーに書ける型を tsc で確かめる

早見表の内容を1つのファイルで確かめました。

// keys.ts
// キーに書ける型
type ByString = { [key: string]: number };
type ByNumber = { [index: number]: string };
type BySymbol = { [key: symbol]: boolean };
type ByPattern = { [key: `data-${string}`]: string };
type ByUnionOfPrimitives = { [key: string | number]: unknown };
type ByBoth = { [key: string]: number; [key: symbol]: string };

// キーに書けない型
type ByLiteralUnion = { [key: "a" | "b"]: number };
type ByLiteral = { [key: "a"]: number };
type ByBoolean = { [key: boolean]: number };
type ByGeneric<K extends string> = { [key: K]: number };
type ByEnumKey = { [key: Color]: number };
enum Color { Red = "red", Blue = "blue" }
type ByOptional = { [key?: string]: number };
type ByBigint = { [key: bigint]: number };
type ByAlias = { [key: Id]: number };
type Id = string;
$ npx tsc --noEmit --strict keys.ts
keys.ts(10,26): error TS1337: An index signature parameter type cannot be a literal type or generic type. Consider using a mapped object type instead.
keys.ts(11,21): error TS1337: An index signature parameter type cannot be a literal type or generic type. Consider using a mapped object type instead.
keys.ts(12,21): error TS1268: An index signature parameter type must be 'string', 'number', 'symbol', or a template literal type.
keys.ts(13,39): error TS1337: An index signature parameter type cannot be a literal type or generic type. Consider using a mapped object type instead.
keys.ts(14,21): error TS1337: An index signature parameter type cannot be a literal type or generic type. Consider using a mapped object type instead.
keys.ts(16,25): error TS1019: An index signature parameter cannot have a question mark.
keys.ts(17,20): error TS1268: An index signature parameter type must be 'string', 'number', 'symbol', or a template literal type.

2〜7行目(string・number・symbol・テンプレートリテラル型・それらのユニオン・併記)と、18行目の「string の別名」にはエラーが出ていません。

リテラルのユニオンは Mapped Types か Record で書く(TS1337)

TS1337 のメッセージは「代わりに mapped object type を検討してください」と案内しています。キーが決まった数種類のときは、in を使う書き方(Mapped Types)か Record に変えます。

// 直す前:TS1337
type ByLevel = { [key: "low" | "mid" | "high"]: number };

// 直した後:どちらも同じ型
type Level = "low" | "mid" | "high";
type ByLevel1 = { [K in Level]: number };
type ByLevel2 = Record<Level, number>;

const lv: ByLevel1 = { low: 1, mid: 2 };
misc.ts(20,7): error TS2741: Property 'high' is missing in type '{ low: number; mid: number; }' but required in type 'ByLevel1'.

こちらは3つのキーがすべて必須になるので、書き漏れ(high)を知らせてくれます。索引シグネチャは「どんなキーでもよい」、Mapped Types と Record は「このキーをすべて持つ」 という違いです。

Record との違い

Record<string, V> と { [key: string]: V } は同じ型です。次のコードで、2つが一致することを確かめました(Equal は2つの型が一致すれば true 型になる検証用の型で、不一致なら TS2322 で止まります)。

type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;

type Dict = { [key: string]: number };
const k5: Equal<Dict, Record<string, number>> = true;   // 通る

違いが出るのは、書ける内容の幅です。

観点 索引シグネチャ Record
キーが string・number・symbol・パターン 書ける 書ける
キーがリテラルのユニオン TS1337 書ける(全キーが必須になる)
固定プロパティと同じ場所に書く 書ける 交差型(&)で足す
キーに説明用の名前を付ける 付けられる([itemName: string]) 付けられない
readonly readonly [key: string]: V Readonly<Record<string, V>>
interface の中に書く 書ける 書けない(extends で継承する)

キーが自由な辞書ならどちらでも同じ、キーが決まっているなら Record、固定プロパティと混ぜたい・キーに名前を付けて意図を伝えたいなら索引シグネチャ、と選べます。

固定プロパティと併用するときの決まり(TS2411)

索引シグネチャと名前の決まったプロパティは、同じ型の中に書けます。ただし、固定プロパティの型は、索引の値の型に収まっている必要があります。

// fixed.ts
// 固定プロパティと併用する
type Headers1 = {
  [key: string]: string;
  contentLength: number;
  accept: string;
};

type Headers2 = {
  [key: string]: string | number;
  contentLength: number;
  accept: string;
};

type Headers3 = {
  [key: string]: string;
  note?: string;
};

interface WithMethod {
  [key: string]: string;
  toText(): string;
}
fixed.ts(4,3): error TS2411: Property 'contentLength' of type 'number' is not assignable to 'string' index type 'string'.
fixed.ts(16,3): error TS2411: Property 'note' of type 'string | undefined' is not assignable to 'string' index type 'string'.
fixed.ts(21,3): error TS2411: Property 'toText' of type '() => string' is not assignable to 'string' index type 'string'.
型 結果 理由
Headers1 TS2411 contentLength が number で、索引の string に収まらない
Headers2 通る 索引を string | number に広げた
Headers3 TS2411 省略可能なプロパティは string | undefined になる
WithMethod TS2411 メソッドも「関数型のプロパティ」として数えられる

obj["contentLength"] と obj.contentLength は同じ値を指すので、両方の型がそろっている必要がある、というのが理由です。

直し方の選択肢

直し方 書き方 向いている場面
索引の値の型を広げる [key: string]: string | number 固定プロパティの型が少ない
省略可能な分を足す [key: string]: string | undefined 省略可能なプロパティがある
自由なキーを1つのプロパティにまとめる { contentLength: number; extra: { [key: string]: string } } 固定部分と自由部分をはっきり分けたい
キーをパターンにする [key: `x-${string}`]: string 自由なキーに接頭辞などの決まりがある

4つ目の「パターンにする」は、固定プロパティの名前がパターンに当てはまらなければ TS2411 の対象になりません。

交差型で足す書き方は、読むときだけ使える

& でつなぐと定義時のエラーは出ませんが、オブジェクトリテラルを代入する場面で止まります。

type Mixed = { contentLength: number } & { [key: string]: string };
const m: Mixed = { contentLength: 10, accept: "text/html" };
declare const m2: Mixed;
const len: number = m2.contentLength;
const acc: string = m2.accept;
fixed.ts(25,7): error TS2322: Type '{ contentLength: number; accept: string; }' is not assignable to type 'Mixed'.
  Type '{ contentLength: number; accept: string; }' is not assignable to type '{ [key: string]: string; }'.
    Property 'contentLength' is incompatible with index signature.
      Type 'number' is not assignable to type 'string'.

読み取り(27・28行目)は通りましたが、値を作る25行目がエラーです。値を自分で作るなら、上の表の直し方を選ぶほうが扱いやすくなります。

存在しないキーを読んだときの型:noUncheckedIndexedAccess の有無を比べる

索引シグネチャは「どんなキーでも値がある」と宣言する書き方です。そのため、既定では、存在しないキーを読んでも型は number のまま になります。

// stock.mts
type Stock = { [itemName: string]: number };

const stock: Stock = { pen: 12, note: 5 };

const pen = stock["pen"];
const eraser = stock["eraser"];   // 存在しないキー

console.log(pen.toFixed(1));
console.log(eraser);
console.log(eraser.toFixed(1));

オプションなし:型チェックは通り、実行すると止まる

$ npx tsc --noEmit --strict --target es2022 --module nodenext stock.mts
$ echo $?
0
$ node stock.mts
12.0
undefined
file:///home/taro/work/stock.mts:10
console.log(eraser.toFixed(1));
                   ^
TypeError: Cannot read properties of undefined (reading 'toFixed')

(パスの表示は環境に合わせて置き換えています。)

strict を付けていても tsc はエラーを出さず、実行時に undefined のメソッド呼び出しで止まりました。

オプションあり:コンパイル時に知らせてくれる

$ npx tsc --noEmit --strict --target es2022 --module nodenext --noUncheckedIndexedAccess stock.mts
stock.mts(8,13): error TS18048: 'pen' is possibly 'undefined'.
stock.mts(10,13): error TS18048: 'eraser' is possibly 'undefined'.

noUncheckedIndexedAccess を有効にすると、索引シグネチャ経由で読んだ値の型に undefined が加わります。 実際に値がある pen も対象です。型の上では、どのキーに値があるかを区別できないためです。

オンとオフで変わる型の一覧

次の表は、各行の式の型を Equal 型で確かめた結果です。

式 オフ オン
stock["pen"](索引シグネチャ) number number | undefined
stock.pen(ドットで読む) number number | undefined
stock[key](key は string 型の変数) number number | undefined
Record<string, number> の rec["pen"] number number | undefined
string[] の list[0] string string | undefined
タプル [string, number] の tuple[0] string string
Record<"pen" | "note", number> の ["pen"] number number
for (const k in stock) の中の stock[k] number number | undefined
if ("pen" in stock) の中の stock["pen"] number number
if (key in stock) の中の stock[key] number number | undefined

キーが決まっている型(タプル、リテラルキーの Record)は影響を受けません。 また、リテラルのキーを in で確かめた後は undefined が外れますが、string 型の変数で確かめた場合は外れませんでした。

書き込みの型は変わりません。オンにしても stock["eraser"] = undefined は次のエラーになります。

t-on.ts(29,1): error TS2322: Type 'undefined' is not assignable to type 'number'.

オンにしたときの書き方

// stock-fix.mts
type Stock = { [itemName: string]: number };

const stock: Stock = { pen: 12, note: 5 };

// 1) undefined を確かめてから使う
const eraser = stock["eraser"];
if (eraser !== undefined) {
  console.log(eraser.toFixed(1));
} else {
  console.log("eraser: 登録なし");
}

// 2) ?? で既定値を決める
const count = stock["eraser"] ?? 0;
console.log(count.toFixed(1));

// 3) リテラルのキーを in で確かめてから読む
if ("pen" in stock) {
  console.log(stock["pen"].toFixed(1));
}

// 4) Object.entries で回す(値は number のまま)
for (const [item, n] of Object.entries(stock)) {
  console.log(item, n.toFixed(0));
}
$ npx tsc --noEmit --strict --target es2022 --module nodenext --noUncheckedIndexedAccess stock-fix.mts
$ node stock-fix.mts
eraser: 登録なし
0.0
12.0
pen 12
note 5
場面 書き方
1件読む 変数に受けて !== undefined で確かめる
なければ既定値でよい ?? で既定値を決める
全件を処理する Object.entries や Object.values で回す
名前のわかっているキー in で確かめる

オプションを使わず、型の側で undefined を含める方法

値の型に undefined を書いておく方法もあります。

type Stock = { [itemName: string]: number | undefined };
方法 読み取り 書き込み
noUncheckedIndexedAccess undefined 込み number だけ
値の型に | undefined を書く undefined 込み undefined も代入できる

オプションは全体に効き、書き込み側の型を保てます。型に書く方法は、その型だけに効かせたいときに選べます。

noPropertyAccessFromIndexSignature:索引由来のキーをドットで読ませない(TS4111)

固定プロパティと索引シグネチャが混ざった型では、env.API_URL と書いたとき、それが「宣言されたプロパティ」なのか「索引で受けているだけ」なのかが見た目でわかりません。このオプションを有効にすると、索引由来のキーはブラケットで書く決まりになります。

// dot.ts
type Env = {
  NODE_ENV: string;            // 固定プロパティ
  [name: string]: string;      // それ以外
};
declare const env: Env;

const a = env.NODE_ENV;        // 固定プロパティ:ドットで読める
const b = env.API_URL;         // 索引シグネチャ由来:ドットで読むと TS4111
const c = env["API_URL"];      // ブラケットなら読める
env.DEBUG = "1";               // 書き込みも同じ扱い
env["DEBUG"] = "1";
$ npx tsc --noEmit --strict dot.ts
$ echo $?
0
$ npx tsc --noEmit --strict --noPropertyAccessFromIndexSignature dot.ts
dot.ts(8,15): error TS4111: Property 'API_URL' comes from an index signature, so it must be accessed with ['API_URL'].
dot.ts(10,5): error TS4111: Property 'DEBUG' comes from an index signature, so it must be accessed with ['DEBUG'].
書き方 オフ オン
env.NODE_ENV(固定プロパティ) 通る 通る
env.API_URL(索引由来・ドット) 通る TS4111
env["API_URL"](索引由来・ブラケット) 通る 通る

ドットで書けるのは宣言されたプロパティだけになるので、つづりの違いに気づきやすくなります。 公式リファレンスはこの設定の目的を、プロパティが存在することへの確信の度合いを、呼び出しの書き方で示せるようにすること、と説明しています。

2つのオプションの比較と設定

オプション 変わること 出るエラー strict だけで有効になるか
noUncheckedIndexedAccess 読んだ値の型に undefined が加わる TS18048・TS2532 ならない
noPropertyAccessFromIndexSignature 索引由来のキーをドットで書けなくなる TS4111 ならない

「strict だけで有効になるか」の列は、--strict だけを付けた tsc で stock.mts と dot.ts がエラーなしになったことから確かめています。使うときは tsconfig.json に個別に書きます。

{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "strict": true,
    "noEmit": true,
    "noUncheckedIndexedAccess": true,
    "noPropertyAccessFromIndexSignature": true
  },
  "include": ["*.mts"]
}

readonly な索引シグネチャ(TS2542)

先頭に readonly を付けると、索引経由の書き込みと削除がエラーになります。

// ro.ts
type Labels = { readonly [code: string]: string };
const labels: Labels = { ok: "完了", ng: "要確認" };

const x = labels["ok"];        // 読み取りは可
labels["ok"] = "済";           // 書き込み
delete labels["ok"];           // 削除

const list: ReadonlyArray<string> = ["a"];
list[0] = "b";

// Readonly<Record<string, string>> も同じ意味
const labels2: Readonly<Record<string, string>> = { ok: "完了" };
labels2["ok"] = "済";

// 入れ子の中までは readonly にならない
type Nested = { readonly [code: string]: { text: string } };
const nested: Nested = { ok: { text: "完了" } };
nested["ok"]!.text = "済";
ro.ts(5,1): error TS2542: Index signature in type 'Labels' only permits reading.
ro.ts(6,8): error TS2542: Index signature in type 'Labels' only permits reading.
ro.ts(9,1): error TS2542: Index signature in type 'readonly string[]' only permits reading.
ro.ts(13,1): error TS2542: Index signature in type 'Readonly<Record<string, string>>' only permits reading.
  • 書き込み(5行目)と delete(6行目)の両方が止まります。
  • 9行目のとおり、ReadonlyArray<string>(readonly string[])も「readonly な number の索引シグネチャ」として同じエラーになります。
  • 18行目にはエラーが出ていません。readonly が効くのは索引の1段目だけ で、値のオブジェクトの中身は書き換えられます。中まで止めたいときは、値の型にも readonly を付けます。

number キーと string キーを併記するときの決まり(TS2413)

1つの型に複数の索引シグネチャを書けます。そのとき、number の索引の値の型は、string の索引の値の型に収まっている必要があります。

// numstr.ts
// number キーと string キーを併記する
type A = {
  [index: number]: string;
  [key: string]: string | number;
};
type B = {
  [index: number]: string | number;
  [key: string]: string;
};
type C = {
  [key: string]: number;
  [key: `id_${string}`]: string;
};
type Dup = {
  [key: string]: number;
  [name: string]: number;
};
numstr.ts(7,3): error TS2413: 'number' index type 'string | number' is not assignable to 'string' index type 'string'.
numstr.ts(12,3): error TS2413: '`id_${string}`' index type 'string' is not assignable to 'string' index type 'number'.
numstr.ts(15,3): error TS2374: Duplicate index signature for type 'string'.
numstr.ts(16,3): error TS2374: Duplicate index signature for type 'string'.
型 結果 理由
A 通る number 側(string)が string 側(string | number)に収まる
B TS2413 number 側のほうが広い
C TS2413 パターンのキーも string の一種なので、同じ決まりが当てはまる
Dup TS2374 同じキーの型の索引は1つだけ。引数名を変えても同じ扱い

公式ハンドブックは理由を、「number で添字アクセスすると、JavaScript は実際にはそれを string に変換してからオブジェクトを引く。100 で引くのは "100" で引くのと同じなので、2つは一貫している必要がある」と説明しています。

number・symbol・パターンのキーで読めるもの

type NumDict = { [index: number]: string };
type SymDict = { [key: symbol]: boolean };
type DataDict = { [key: `data-${string}`]: string };

const nd: NumDict = { 0: "a", 1: "b" };
const n0 = nd[0];
const n1 = nd["1"];
const n2 = nd["one"];

const tag = Symbol("tag");
const sd: SymDict = { [tag]: true };
const s0 = sd[tag];
const s1 = sd["tag"];

const dd: DataDict = { "data-id": "7", "data-role": "admin" };
const dd2: DataDict = { "data-id": "7", title: "x" };
const d0 = dd["data-id"];
const d1 = dd["title"];
misc.ts(26,15): error TS7015: Element implicitly has an 'any' type because index expression is not of type 'number'.
misc.ts(32,12): error TS7053: Element implicitly has an 'any' type because expression of type '"tag"' can't be used to index type 'SymDict'.
  Property 'tag' does not exist on type 'SymDict'.
misc.ts(36,41): error TS2353: Object literal may only specify known properties, and 'title' does not exist in type 'DataDict'.
misc.ts(38,12): error TS7053: Element implicitly has an 'any' type because expression of type '"title"' can't be used to index type 'DataDict'.
  Property 'title' does not exist on type 'DataDict'.
キーの型 読める 読めない
number nd[0]、数字の文字列 nd["1"] nd["one"](TS7015)
symbol sd[tag] sd["tag"](TS7053)
`data-${string}` dd["data-id"] dd["title"](TS7053)。リテラルに title を書くと TS2353

パターンのキーは「data- で始まるキーだけを受け付ける辞書」になり、string の索引より範囲を絞れます。

keyof の結果もキーの型によって変わります。

型 keyof の結果
{ [key: string]: number } string | number
{ [index: number]: string } number
{ [key: symbol]: boolean } symbol
{ [key: `data-${string}`]: string } `data-${string}`

string の索引で keyof が string | number になるのは、数値で引いても文字列に変換されて同じキーを指すためです。

interface と type で違う点:暗黙の索引シグネチャ(TS2345)

索引シグネチャを受け取る関数に、キーの決まったオブジェクトを渡す場面です。type で作った型は渡せますが、interface で作った型は渡せません。

// implicit.ts
function printAll(data: { [key: string]: string }) {
  for (const [k, v] of Object.entries(data)) console.log(k, v);
}

type UserT = { name: string; mail: string };
interface UserI { name: string; mail: string }

const t: UserT = { name: "sato", mail: "sato@example.com" };
const i: UserI = { name: "sato", mail: "sato@example.com" };

printAll(t);          // type で作った型:通る
printAll(i);          // interface で作った型:TS2345
printAll({ ...i });   // スプレッドで写すと通る

interface UserI2 { name: string; mail: string; [key: string]: string }
const i2: UserI2 = { name: "sato", mail: "sato@example.com" };
printAll(i2);         // 索引シグネチャを明示した interface:通る

type WithAge = { name: string; age: number };
const w: WithAge = { name: "sato", age: 30 };
printAll(w);          // 値の型が合わない:TS2345
implicit.ts(12,10): error TS2345: Argument of type 'UserI' is not assignable to parameter of type '{ [key: string]: string; }'.
  Index signature for type 'string' is missing in type 'UserI'.
implicit.ts(21,10): error TS2345: Argument of type 'WithAge' is not assignable to parameter of type '{ [key: string]: string; }'.
  Property 'age' is incompatible with index signature.
    Type 'number' is not assignable to type 'string'.
渡したもの 結果 エラーの2行目
type で作った型(全プロパティが string) 通る なし
interface で作った型 TS2345 Index signature for type 'string' is missing
interface の値をスプレッドで写したもの 通る なし
索引シグネチャを明示した interface 通る なし
type で作った型(number のプロパティあり) TS2345 Property 'age' is incompatible with index signature

type で作ったオブジェクト型は、プロパティがすべて索引の値の型に合っていれば、索引シグネチャを持つものとして扱われます(暗黙の索引シグネチャ)。 interface にはこの扱いがありません。interface は後から同じ名前で宣言を足してプロパティを増やせるため、「今あるプロパティがすべて」とは言い切れないことが背景にあります。

直し方

直し方 書き方
渡す側を type にする type UserT = { name: string; mail: string }
渡すときに写す printAll({ ...i })
interface に索引を明示する [key: string]: string を足す
受け取る側をジェネリクスにする function printAll<T extends Record<keyof T, string>>(data: T)

4つ目は受け取る関数を変えられるときの方法で、interface の値をそのまま渡せます。

コピペ用:状態ごとの件数と担当者ごとの一覧を作る

2つのオプションを有効にした状態で動く、集計の完成形です。上の tsconfig.json と同じフォルダに置いて実行できます。

// tally.mts
// タスクを状態ごとに数え、担当者ごとにまとめる
type Task = { title: string; status: string; owner: string };

type Counter = { [status: string]: number };
type Groups = { [owner: string]: Task[] };

const tasks: Task[] = [
  { title: "見積もりを作る", status: "done", owner: "sato" },
  { title: "資料を直す", status: "doing", owner: "suzuki" },
  { title: "備品を数える", status: "todo", owner: "sato" },
  { title: "日程を決める", status: "done", owner: "suzuki" },
  { title: "議事録を書く", status: "done", owner: "sato" },
];

function countByStatus(list: Task[]): Counter {
  const counter: Counter = {};
  for (const t of list) {
    counter[t.status] = (counter[t.status] ?? 0) + 1;
  }
  return counter;
}

function groupByOwner(list: Task[]): Groups {
  const groups: Groups = {};
  for (const t of list) {
    (groups[t.owner] ??= []).push(t);
  }
  return groups;
}

const counter = countByStatus(tasks);
console.log(counter);
console.log("done:", counter["done"] ?? 0);
console.log("hold:", counter["hold"] ?? 0);

const groups = groupByOwner(tasks);
for (const [owner, list] of Object.entries(groups)) {
  console.log(owner, list.map((t) => t.title).join(" / "));
}
$ npx tsc -p .
$ node tally.mts
{ done: 3, doing: 1, todo: 1 }
done: 3
hold: 0
sato 見積もりを作る / 備品を数える / 議事録を書く
suzuki 資料を直す / 日程を決める

Node.js 22.22.2 は、型注釈だけの TypeScript ファイルをそのまま実行できます。

集計の定番は (counter[key] ?? 0) + 1 と (groups[key] ??= []).push(...) の2つ です。どちらも「まだキーがない」場合を式の中で扱うので、オプションを有効にしていてもエラーになりません。

?? を省いた書き方と、ドットでの読み取りを同じ設定で確かめると、2つのオプションがそれぞれ知らせてくれます。

type Counter = { [status: string]: number };
const counter: Counter = { done: 3 };
counter["doing"] = counter["doing"] + 1;
console.log(counter.done);
ng.mts(3,20): error TS2532: Object is possibly 'undefined'.
ng.mts(4,21): error TS4111: Property 'done' comes from an index signature, so it must be accessed with ['done'].

エラー番号から直し方を引く

番号 メッセージ(要約) 原因 直し方
TS1337 cannot be a literal type or generic type キーにリテラル・型引数・enum を書いた { [K in Keys]: V } か Record<Keys, V> にする
TS1268 must be 'string', 'number', 'symbol', or a template literal type キーに boolean などを書いた 書ける4種類のどれかにする
TS1019 cannot have a question mark キーに ? を付けた ? を外す(索引はもともと任意のキー)
TS2411 Property ... is not assignable to 'string' index type 固定プロパティの型が索引の値の型に収まらない 索引の値の型を広げる、または自由なキーを別プロパティに分ける
TS2413 'number' index type ... is not assignable to 'string' index type number やパターンの索引が string の索引より広い string 側を広げる
TS2374 Duplicate index signature 同じキーの型の索引を2つ書いた 1つにまとめる
TS18048 / TS2532 possibly 'undefined' noUncheckedIndexedAccess が有効 ??、undefined の確認、Object.entries
TS4111 comes from an index signature noPropertyAccessFromIndexSignature が有効 obj["key"] と書く
TS2542 only permits reading readonly な索引へ書き込んだ 新しいオブジェクトを作る
TS2345 Index signature for type 'string' is missing interface の値を索引シグネチャの引数へ渡した type にする、スプレッドで写す、索引を明示する
TS7015 / TS7053 implicitly has an 'any' type 索引のキーの型に合わないキーで読んだ キーの型に合う値で読む

よくある質問

Q. [key: string] の key は別の名前でもよいですか? A. はい。説明用の名前で、型の動きには影響しません。[itemName: string] や [status: string] のように、何のキーかが伝わる名前にすると読みやすくなります。名前を変えても同じキーの型の索引は1つしか書けません(TS2374)。

Q. Record と索引シグネチャ、どちらで書くとよいですか? A. Record<string, V> と { [key: string]: V } は同じ型なので、キーが自由な辞書ならどちらでも構いません。キーがリテラルのユニオンなら Record(索引シグネチャでは TS1337)、固定プロパティと同じ場所に書きたい・キーに名前を付けたいなら索引シグネチャを選びます。

Q. noUncheckedIndexedAccess は strict に含まれますか? A. TypeScript 7.0.2 で --strict だけを付けて確かめたところ、有効になりませんでした。tsconfig.json に "noUncheckedIndexedAccess": true を個別に書きます。noPropertyAccessFromIndexSignature も同じです。

Q. 配列の list[0] にも影響しますか? A. noUncheckedIndexedAccess を有効にすると、string[] の list[0] は string | undefined になります。長さの決まったタプルの tuple[0] は変わりません。

まとめ

  • インデックスシグネチャは { [key: string]: V } と書き、キーの名前を決めずに値の型だけを決める。 Record<string, V> と同じ型
  • キーに書けるのは string・number・symbol・テンプレートリテラル型(とそのユニオン)。 リテラルのユニオンは TS1337 になるので、Mapped Types か Record にする
  • 固定プロパティの型は、索引の値の型に収まっている必要がある(TS2411)。 省略可能なプロパティとメソッドも対象
  • 既定では、存在しないキーを読んでも undefined 込みの型にならない。 noUncheckedIndexedAccess を有効にすると、コンパイル時に知らせてくれる
  • noPropertyAccessFromIndexSignature を有効にすると、索引由来のキーはブラケットで書く決まりになる(TS4111)
  • readonly を付けると書き込みと delete が止まる(TS2542)。 効くのは1段目だけ
  • type で作った型は索引シグネチャの引数へ渡せるが、interface で作った型は TS2345 になる。 スプレッドで写すか、索引を明示する

参考

  • TypeScript Handbook「Object Types」(Index Signatures): https://www.typescriptlang.org/docs/handbook/2/objects.html
  • TSConfig リファレンス noUncheckedIndexedAccess: https://www.typescriptlang.org/tsconfig/noUncheckedIndexedAccess.html
  • TSConfig リファレンス noPropertyAccessFromIndexSignature: https://www.typescriptlang.org/tsconfig/noPropertyAccessFromIndexSignature.html

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「TypeScript インデックスシグネチャの使い方|Recordとの違い」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?