How to type an object with dynamic keys.
Three approaches, one answer.
You have an object where the keys aren't known upfront — a dictionary, a lookup table, a cache. TypeScript needs a type for it, and the syntax isn't obvious. Here's the answer: Record for known keys, index signatures for open-ended keys, and generics when the value type depends on the key.
Short answer: Use Record when the keys are known (Record<'admin' | 'user', Permission[]>). Use { [key: string]: V } when keys are truly open-ended. Use Map when keys aren't strings. Record is almost always the right choice — it constrains keys and values, where a plain index signature only constrains values.
✅ Solution 1 — Record (the modern default)
When the set of keys is known at compile time.
Record takes two type parameters: the key type and the value type. If the keys are a union of literal strings — 'admin' | 'user' | 'guest' — the resulting type has exactly those three keys, with the value type for each. This is the safest way to type a dictionary, and it's what every library uses.
// ─── Record with known keys ───────────────── type Role = 'admin' | 'user' | 'guest'; type Permissions = Recordstring[]>; const perms: Permissions = { admin: ['read', 'write', 'delete'], user: ['read', 'write'], guest: ['read'] // ❌ Missing 'guest' → compile error (Record enforces all keys) }; // ─── Autocomplete + typo protection ───────── perms.admin; // ✅ string[] — autocomplete works perms.admim; // ❌ Property 'admim' does not exist // ─── Record with value transforms ─────────── type Status = 'idle' | 'loading' | 'success' | 'error'; type StatusLabels = Record string>; const labels: StatusLabels = { idle: 'Ready', loading: 'Loading…', success: 'Done', error: 'Failed' }; // ─── Record with a general string key ─────── // If keys really are arbitrary strings: type StringDict = Record<string, number>; const scores: StringDict = { alice: 90, bob: 85 }; // ⚠️ No typo protection — any string key works. // Prefer a literal union when you can. console.log(perms.admin, labels.success, scores.alice);
// Record type erased at compile time. Only values remain. const perms = { admin: ['read', 'write', 'delete'], user: ['read', 'write'], guest: ['read'] }; perms.admin; // perms.admim; ← would return undefined in JS. TS catches it. const labels = { idle: 'Ready', loading: 'Loading…', success: 'Done', error: 'Failed' }; const scores = { alice: 90, bob: 85 }; console.log(perms.admin, labels.success, scores.alice);
Two type params
Record — K is the key type (usually a literal union), V is the value type. Both are required.
Enforces all keys
If K is 'a' | 'b' | 'c', the object must have all three. This catches "forgot a role" bugs at compile time.
Autocomplete works
TypeScript knows the exact set of keys. Hovering over the object shows all valid keys. Typos fail to compile.
When K is string
Record allows any string key — equivalent to an index signature. Use a literal union whenever possible.
Record is the single most-used utility type in production code. Look at any codebase's config, i18n messages, role maps, or feature flags — they're all Record. The pattern is universal: define your keys as a union type, then build a Record from it. When you see { [key: string]: T } in code, ask "could these keys be a union?" If yes, replace with Record — you gain compile-time key checking, autocomplete, and automatic exhaustiveness.
✅ Solution 2 — Index signature (open-ended keys)
When keys are genuinely arbitrary — parsed JSON, unknown API responses.
An index signature { [key: string]: V } says "any string key maps to a value of type V." Unlike Record, it doesn't restrict which keys exist — any key is valid, and any key returns V. Use it only when you can't enumerate the keys, like when parsing arbitrary JSON.
// ─── Index signature ───────────────────────── interface StringDict { [key: string]: number; } const scores: StringDict = { alice: 90, bob: 85 // any string key works — no restriction }; scores.charlie = 77; // ✅ any string key allowed // scores.dave = 'A'; // ❌ value must be number // ─── Type of unknown keys ──────────────────── // Accessing an arbitrary key returns V, NOT V | undefined. // TypeScript doesn't know if the key exists. const x: number = scores.nonexistent; // ⚠️ compiles, but is undefined at runtime // ─── Add known keys alongside ──────────────── interface Config { [key: string]: string; version: string; // known key — must match index signature type host: string; } const cfg: Config = { version: '1.0', host: 'localhost', extra: 'value' // ✅ allowed by index signature }; // ─── When to use this vs Record ────────────── // Use index signature when keys are parsed at runtime: // const data: unknown = JSON.parse(text); // if (isStringDict(data)) { ... } console.log(scores.alice, cfg.version);
// Index signature erased. Runtime is a plain object. const scores = { alice: 90, bob: 85 }; scores.charlie = 77; // scores.dave = 'A'; ← TS prevents; JS allows (bug) const x = scores.nonexistent; // undefined const cfg = { version: '1.0', host: 'localhost', extra: 'value' }; console.log(scores.alice, cfg.version);
Any key allowed
{ [key: string]: V } — any string key works. No enumeration, no autocomplete. The value type is still checked.
Accessing is unsafe
dict.anything returns V, not V | undefined. TypeScript can't know if the key exists. Check with key in obj or use Record instead.
Can combine with known keys
A type can have both specific properties and an index signature. The specific ones must match the index signature's value type.
Legitimate use: JSON parsing
The one case where index signatures are right: when keys come from runtime data you can't enumerate. Narrow the result with a type guard.
Index signatures don't give you the safety you might expect. scores.nonexistent compiles and returns undefined at runtime — TypeScript assumes every string key exists. This is why Record with a literal union is preferred: it makes which keys exist part of the type. Reach for index signatures only when keys truly can't be enumerated (parsed JSON, feature flags, i18n dictionaries loaded at runtime).
✅ Solution 3 — Map (non-string keys)
When keys are numbers, booleans, or objects — not strings.
Plain objects only support string and symbol keys. If you need number keys, object keys, or any other type, use Map. It's a standard library feature with typed .get, .set, .has, .delete, and correct handling of missing keys (undefined, not a silent assumption).
// ─── Map with number keys ─────────────────── const userById = new Map<number, string>(); userById.set(1, 'Alice'); userById.set(2, 'Bob'); userById.get(1); // string | undefined userById.has(3); // boolean — safe check // userById.set('x', 'y'); // ❌ key must be number // ─── Map with object keys ─────────────────── interface User { id: number; name: string } const scores = new Mapnumber>(); const alice: User = { id: 1, name: 'Alice' }; scores.set(alice, 100); // ─── Map with string keys (still fine) ────── const flags = new Map<string, boolean>(); flags.set('darkMode', true); flags.set('notifications', false); // ─── Iterating a Map is predictable ───────── for (const [id, name] of userById) { console.log(id, name); // id: number, name: string } // ─── Size is O(1) ─────────────────────────── console.log(userById.size); // number console.log(userById.get(1), flags.get('darkMode'));
// Map is a runtime class — no erasure. Types are annotations only. const userById = new Map(); userById.set(1, 'Alice'); userById.set(2, 'Bob'); userById.get(1); userById.has(3); const scores = new Map(); const alice = { id: 1, name: 'Alice' }; scores.set(alice, 100); const flags = new Map(); flags.set('darkMode', true); flags.set('notifications', false); for (const [id, name] of userById) { console.log(id, name); } console.log(userById.size); console.log(userById.get(1), flags.get('darkMode'));
Any key type
Map accepts any K, including numbers, booleans, objects, and other Maps. Plain objects only support strings and symbols.
Safe missing keys
.get(k) returns V | undefined — TypeScript forces you to handle the missing case. .has(k) gives a boolean check.
Ordered iteration
Maps preserve insertion order. Iterating with for...of gives [K, V] tuples — correctly typed.
O(1) size
.size is O(1) on Maps. On plain objects, you need Object.keys(obj).length, which is O(n).
Choose based on key type, not habit. If keys are strings or a literal union of strings → Record. If keys are numbers, objects, or anything else → Map. If keys are truly arbitrary strings you can't enumerate (parsed JSON) → index signature. That's the entire decision tree. Ninety percent of the time, it's Record. The other ten percent, it's Map. Index signatures are the rarest — and they should be.
⚠️ Common mistakes
The dynamic-key bugs that catch every beginner.
{ [key: string]: number } for a fixed set of role names loses all type safety. Fix: Record<'admin' | 'user', number>. Autocomplete, typo protection, exhaustiveness — all free.
scores.nonexistent compiles and returns undefined. Fix: use Record for known keys, or check with if (key in obj) before access.
any as the value type{ [key: string]: any } defeats the point. Fix: { [key: string]: User } or Record. The value type is the whole check.
{ [1]: 'a' } in JS uses the string '1' as the key. Fix: use Map if you truly need numeric keys.
as constconst k = 'name'; const obj = { [k]: 'Alice' } — obj has type { [x: string]: string }, not { name: string }. Fix: const k = 'name' as const; — then the key is the literal type.
for...offor (const k of obj) fails — objects aren't iterable. Fix: Object.keys(obj).forEach(k => ...), or use Map if iteration is core to the design.
Record with MapThey're different at runtime — Record is a plain object, Map is a class. Fix: use Record for literal objects, Map for mutable collections that need .size, iteration, or non-string keys.
Record everywhereRecord allows any string key — same problem as an index signature. Fix: constrain K to a union of known strings whenever possible. Only fall back to string when the keys are truly open-ended.
❓ Frequently asked
How do I type an object with dynamic keys in TypeScript?
Use Record
What is the difference between Record and index signature?
Both describe dictionaries. The index signature { [key: string]: T } allows any string key — no autocomplete, no typo protection. Record<'a' | 'b', T> restricts keys to a known union — autocomplete works, and using an unknown key is a compile error. Prefer Record when the keys are known at compile time; use index signatures only for truly open-ended keys like parsed JSON.
Can I type dynamic keys with computed property names?
Yes, but TypeScript requires the key to be a literal type. const key = 'name' as const; const obj = { [key]: 'Alice' } — this works and gives obj the type { name: string }. Without as const, key is inferred as string, and the computed property produces an index signature. Use as const on the key, or define the shape with Record.
How do I type a Map with dynamic keys?
Use Map