Problem Solved

TypeScript · dynamic keys

Home › Courses › TypeScript › Problems › Dynamic keys
🔷 Problem · 5 min read

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.ts
// ─── 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 = Recordstring>;

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.ts
// ─── 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.ts
// ─── 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.

1. Using index signature when you know the keys

{ [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.

2. Expecting index signature to catch missing keys

scores.nonexistent compiles and returns undefined. Fix: use Record for known keys, or check with if (key in obj) before access.

3. Using any as the value type

{ [key: string]: any } defeats the point. Fix: { [key: string]: User } or Record. The value type is the whole check.

4. Forgetting that number keys coerce to strings

{ [1]: 'a' } in JS uses the string '1' as the key. Fix: use Map if you truly need numeric keys.

5. Using computed property names without as const

const 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.

6. Trying to iterate Record with for...of

for (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.

7. Confusing Record with Map

They'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.

8. Using Record everywhere

Record 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 when you know the set of keys (e.g. Record<'admin' | 'user', Permissions>). Use an index signature { [key: string]: V } when keys are truly open-ended. Use a Map when keys are non-string. Record is almost always the right choice — it constrains both keys and values, while a plain index signature only constrains values.

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 instead of a plain object when keys can be numbers, booleans, or objects — not just strings. new Map() has typed .get, .set, .has. Plain objects only support string and symbol keys; Maps support any key type. For string-keyed dictionaries, Record is usually simpler than Map.

🔗 Related

← All TypeScript problems
Problem solved · TypeScript