Ch. 2 · TypeScript

TypeScript Index Signatures and Constraints

How index signatures constrain every property, why reads skip undefined until noUncheckedIndexedAccess, and when Record or Map fits better.

~6 min readintermediateupdated Oct 19, 2026

An index signature tells TypeScript that an object can have keys you don’t know in advance: { [word: string]: number } means “any string key, and every value is a number”. It’s the natural type for a lookup table, a cache or a parsed config. It’s also one of the places where TypeScript’s default settings let a real bug through, and where the runtime behaviour of plain JavaScript objects bites people who assume an object is just a dictionary.

A word counter with two bugs

Here’s a program that compiles without errors under --strict:

const counts: { [word: string]: number } = {};

for (const word of 'the cat saw the constructor'.split(' ')) {
  counts[word] = (counts[word] || 0) + 1;
}
console.log(counts);
console.log(counts['dog'].toFixed(1));
TypeScript

Run it with Node 22 and you get:

{
  the: 2,
  cat: 1,
  saw: 1,
  constructor: 'function Object() { [native code] }1'
}
TypeError: Cannot read properties of undefined (reading 'toFixed')
Text

Both lines of output point at something the type didn’t tell you.

The read of a missing key. counts['dog'] has type number according to TypeScript, but the key doesn’t exist, so the value is undefined. An index signature is a promise that if a key exists, its value has this type. TypeScript’s default reading is more generous: it assumes every key you ask for is there. The flag that fixes this is noUncheckedIndexedAccess. It isn’t part of strict, so you have to turn it on separately. With it, the same file fails to compile:

counts.ts(7,13): error TS2532: Object is possibly 'undefined'.
Text

Now every indexed read has type number | undefined, and you have to handle the missing case with ?? 0, a guard or an in check. It’s noisy in loops over arrays (arr[i] becomes T | undefined too), which is why some teams resist it. For code that indexes objects with keys from user input or external data, it catches exactly the bug above.

The inherited key. The word “constructor” collided with Object.prototype.constructor. counts['constructor'] wasn’t missing: it found the inherited Object function, || 0 didn’t trigger because a function is truthy, and + 1 turned the function into a string and appended 1. TypeScript can’t see this at all. The type says “string keys to numbers”; the runtime says “string keys to numbers, plus whatever the prototype chain has”. Keys like toString, hasOwnProperty and __proto__ cause the same class of bug, and __proto__ in particular has been the root of prototype-pollution vulnerabilities.

Use a Map when keys come from data

For keys that come from text, users or another system, a Map avoids both problems:

function countWords(text: string): Map<string, number> {
  const counts = new Map<string, number>();
  for (const word of text.split(/\s+/).filter(Boolean)) {
    counts.set(word, (counts.get(word) ?? 0) + 1);
  }
  return counts;
}
const counts = countWords('the cat saw the constructor');
console.log(counts);
console.log((counts.get('dog') ?? 0).toFixed(1));
TypeScript
Map(4) { 'the' => 2, 'cat' => 1, 'saw' => 1, 'constructor' => 1 }
0.0
Text

Map.prototype.get is typed to return V | undefined regardless of compiler flags, so the missing-key case is handled because the compiler requires it. A Map has no inherited keys, preserves insertion order, and accepts non-string keys. The trade-off is serialisation: JSON.stringify(new Map(...)) gives {}, so convert with Object.fromEntries(map) before sending it over the wire.

If you need a plain object (because it’s going straight into JSON, say), create it with Object.create(null) so it has no prototype, or check Object.hasOwn(obj, key) before reading.

What an index signature constrains

An index signature isn’t only about unknown keys. It also constrains every known property in the same type. These rules surprise people:

interface Headers1 {
  [name: string]: string;
  contentLength: number;
}
interface Mixed {
  [i: number]: string | number;
  [k: string]: string;
}
interface DataAttrs {
  [attr: `data-${string}`]: string;
}
const ok: DataAttrs = { 'data-testid': 'save' };
const bad: DataAttrs = { 'aria-label': 'Save' };
TypeScript
rules.ts(3,3): error TS2411: Property 'contentLength' of type 'number' is not assignable to 'string' index type 'string'.
rules.ts(6,3): error TS2413: 'number' index type 'string | number' is not assignable to 'string' index type 'string'.
rules.ts(13,26): error TS2353: Object literal may only specify known properties, and ''aria-label'' does not exist in type 'DataAttrs'.
Text

Named properties must fit the signature. contentLength is a string key too, so obj['contentLength'] has to satisfy both the named property and the index signature. If you need a mix, widen the signature (string | number) or, usually better, separate the known fields from the open-ended ones: { contentLength: number; extra: Record<string, string> }.

Number keys are string keys at runtime. obj[1] and obj['1'] read the same property, so a number index signature’s value type must be assignable to the string one.

Keys can be patterns. Since TypeScript 4.4, the key can be a template literal pattern or symbol. DataAttrs accepts any data-* attribute and rejects the rest, which is a neat way to type framework props that pass through attributes with a prefix.

A related point: once a type has a string index signature, excess property checks no longer help. const c: { [k: string]: number } = { retries: 3, retires: 5 } compiles, typo and all, because every key is allowed. If you know the keys, say so in the type.

Record, finite keys and the interface gotcha

Record<K, V> is a mapped type, and its behaviour depends on K. Record<string, V> is equivalent to { [key: string]: V }. Record<'en' | 'de' | 'fr', V> is something else entirely: an object with exactly those three required properties.

type Locale = 'en' | 'de' | 'fr';
const greetings: Record<Locale, string> = { en: 'Hello', de: 'Hallo' };
const partial: Partial<Record<Locale, string>> = { en: 'Hello' };
const loose: { [locale: string]: string } = { en: 'Hello', dee: 'Hallo' };
const g = partial.fr;
const upper = g.toUpperCase();
TypeScript
rec.ts(2,7): error TS2741: Property 'fr' is missing in type '{ en: string; de: string; }' but required in type 'Record<Locale, string>'.
rec.ts(6,15): error TS18048: 'g' is possibly 'undefined'.
Text

This is where Record shines. Add 'es' to Locale and every translation table that lacks Spanish fails to compile, which is exactly the reminder you want. Partial<Record<...>> keeps the key checking (no dee typo allowed) while admitting that some entries are missing, and reads come back as string | undefined even without noUncheckedIndexedAccess. The loose version accepts the typo dee silently.

The last gotcha comes up in interviews often. A function that accepts “any object of string keys” is usually typed Record<string, unknown>:

interface User { id: number; name: string }
type UserT = { id: number; name: string };
function log(fields: Record<string, unknown>) {}
declare const u: User;
declare const ut: UserT;
log(u);
log(ut);
TypeScript
log.ts(6,5): error TS2345: Argument of type 'User' is not assignable to parameter of type 'Record<string, unknown>'.
  Index signature for type 'string' is missing in type 'User'.
Text

The type alias passes and the interface, with identical members, doesn’t. Object literal types written with type get an implicit index signature for this check; interfaces don’t, because interfaces can be extended later by declaration merging, so TypeScript can’t be sure all their properties will fit. The usual fixes are to type the parameter as object or {}, make it generic (<T extends object>(fields: T)), or use a type alias for the data.

Practice question

A team stores feature flags as const flags: { [name: string]: boolean } = await loadFlags() and checks them with if (flags[name]) enable(name). A product manager reports that a flag named toString is “always on”. A second report says that misspelled flag names never produce an error. Explain both, and suggest a type that would catch the second problem.

Answer and reasoning

The first bug is runtime: flags is a plain object, so flags['toString'] finds Object.prototype.toString when the flag isn’t defined. A function is truthy, so the if passes. The index signature can’t describe inherited members, so TypeScript reports nothing. The fix is to load flags into a Map<string, boolean>, an Object.create(null) object, or to check Object.hasOwn(flags, name) first.

The second bug is the type’s fault. { [name: string]: boolean } accepts every string key, so flags['new-chekout'] compiles and returns undefined, which is falsy, so the feature just stays off. If the flag names are known at build time, declare them: type FlagName = 'new-checkout' | 'dark-mode' | 'beta-search' and Record<FlagName, boolean>. Typos then fail with an error, adding a new flag forces every flag source to provide it, and a runtime validator (for example, comparing loaded keys with a FLAG_NAMES array defined as const) can report flags the server sent that the code doesn’t know about. If names really are dynamic, keep the Map and turn on noUncheckedIndexedAccess so every read has to decide what a missing flag means.

More in TypeScript

esc