How to use Record.
Typed dictionaries, done right.
Record is one of TypeScript's most-used utility types. It builds a typed dictionary: keys of type K, values of type V. It's how you type config maps, lookup tables, and any object where every key has the same value type. This page covers the syntax, the patterns, and the critical difference from index signatures.
Short answer: Record creates an object type with keys K and values V. Use Record<'admin' | 'user' | 'guest', boolean> for known keys — you get autocomplete, typo protection, and enforcement of all keys. Use Record for open-ended dictionaries. Prefer Record over { [key: string]: V } whenever the keys are known at compile time — it's strictly safer. For numeric keys at runtime, use Map.
✅ Solution 1 — Record with known keys (the ideal)
Union of literals — the strongest form of dictionary typing.
When you know exactly which keys exist — roles, statuses, locales, event names — Record is the correct type. It enforces that every key is present, every value has the right type, and every property access is autocomplete-friendly. This is the pattern behind every config map, i18n dictionary, and lookup table in production code.
// ─── Role permissions ──────────────────────── 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 perms.admim; // ❌ Property 'admim' does not exist // ─── i18n message dictionary ───────────────── type Locale = 'en' | 'fr' | 'es'; type MessageKey = 'greeting' | 'farewell'; type Translations = Record Record string>>; const messages: Translations = { en: { greeting: 'Hello', farewell: 'Goodbye' }, fr: { greeting: 'Bonjour', farewell: 'Au revoir' }, es: { greeting: 'Hola', farewell: 'Adiós' } }; function t(locale: Locale, key: MessageKey): string { return messages[locale][key]; // both indexed by literal unions } // ─── Status → label map ────────────────────── type Status = 'idle' | 'loading' | 'success' | 'error'; const statusLabels: Record string> = { idle: 'Ready', loading: 'Loading…', success: 'Done', error: 'Failed' }; // Type-safe access: function label(s: Status): string { return statusLabels[s]; } // ─── Record with generic type ──────────────── interface User { id: string; name: string } function indexById(users: User[]): Record<string, User> { const index: Record<string, User> = {}; for (const u of users) { index[u.id] = u; } return index; } const users = indexById([ { id: '1', name: 'Alice' }, { id: '2', name: 'Bob' } ]); // ─── Why Record beats an index signature here // // Index sig: { [key: string]: User } // ❌ No autocomplete — any key allowed // ❌ No typo protection // ❌ No "must have all keys" enforcement // // Record : // ✅ Autocomplete on keys // ✅ Typos are compile errors // ✅ Missing keys are compile errors // ✅ Exact key list is enforced console.log(perms.admin, t('fr', 'greeting')); console.log(label('success'), users['1']);
// Record erased. Runtime objects remain. const perms = { admin: ['read', 'write', 'delete'], user: ['read', 'write'], guest: ['read'] }; const messages = { en: { greeting: 'Hello', farewell: 'Goodbye' }, fr: { greeting: 'Bonjour', farewell: 'Au revoir' }, es: { greeting: 'Hola', farewell: 'Adiós' } }; function t(locale, key) { return messages[locale][key]; } const statusLabels = { idle: 'Ready', loading: 'Loading…', success: 'Done', error: 'Failed' }; function label(s) { return statusLabels[s]; } function indexById(users) { const index = {}; for (const u of users) { index[u.id] = u; } return index; } const users = indexById([ { id: '1', name: 'Alice' }, { id: '2', name: 'Bob' } ]); console.log(perms.admin, t('fr', 'greeting')); console.log(label('success'), users['1']);
Two type parameters
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. Missing keys are a compile error — not silent runtime undefined.
Autocomplete works
TypeScript knows the exact set of keys. Hovering over the object shows all valid keys. Typos fail to compile.
Nested Records
Record — one Record per level. Both dimensions are typed. Common for i18n.
Record is the single most-used utility type in production code. Look at any codebase's config, i18n, 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 — Record vs index signatures
When keys are truly open-ended — and how it differs from an index signature.
Sometimes you don't know the keys — parsed JSON, dynamic feature flags, unvalidated API data. For those cases, you need an open-ended dictionary. Record and { [key: string]: V } are functionally equivalent, but Record reads more clearly and composes better with other utility types.
// ─── Recordvs index signature ──── // These two are equivalent: type A = Record<string, number>; interface B { [key: string]: number; } // Prefer Record — it reads as "a dictionary", // it composes with utility types, and it's the // modern idiom. Index signatures are legacy syntax. // ─── When keys are truly open ──────────────── function parseCounts(json: string): Record<string, number> { const raw: unknown = JSON.parse(json); // Validate before casting if (typeof raw !== 'object' || raw === null) { throw new Error('Expected an object'); } const result: Record<string, number> = {}; for (const [k, v] of Object.entries(raw)) { if (typeof v !== 'number') throw new Error(Non-number value for ${k}`); result[k] = v; } return result; } // ─── Record with any key type ──────────────── type NumberDict = Record<number, string>; const scoreByName: NumberDict = { [1]: 'Alice', [2]: 'Bob' }; // Note: JS coerces number keys to strings at runtime. // For true numeric keys, use Map. // ─── Combining Record with Partial ─────────── // Allow some keys to be missing (partial dictionary): type PartialConfig = Partial<Record<'host' | 'port' | 'debug', string>>; const cfg: PartialConfig = { host: 'localhost' }; // ✅ only host // const bad: Record<'host'|'port', string> = { host: 'x' }; // ❌ missing 'port' — Record is strict by default // ─── Combining Record with Pick/Omit ───────── interface Settings { theme: 'light' | 'dark'; fontSize: number; language: string; } // A Record of specific settings: type ThemeSettings = Pick'theme' | 'fontSize'>; // Or a Record of user IDs to their settings: type UserSettings = Record<string, Settings>; console.log(parseCounts('{"a":1,"b":2}')); console.log(scoreByName, cfg);
function parseCounts(json) { const raw = JSON.parse(json); if (typeof raw !== 'object' || raw === null) { throw new Error('Expected an object'); } const result = {}; for (const [k, v] of Object.entries(raw)) { if (typeof v !== 'number') throw new Error(Non-number value for${k}`); result[k] = v; } return result; } const scoreByName = { [1]: 'Alice', [2]: 'Bob' }; const cfg = { host: 'localhost' }; console.log(parseCounts('{"a":1,"b":2}')); console.log(scoreByName, cfg);
Record = open dict
Equivalent to { [key: string]: V }. No key enumeration. Use for parsed JSON, dynamic keys, feature flags.
Prefer Record over index sig
Same semantics, cleaner syntax, composes with utility types. Partial works; Partial<{[k:string]: V}> is awkward.
Number keys coerce
Record — the keys become strings at runtime. For true numeric keys, use Map.
Combine with Partial
Partial — all keys optional. Useful for incremental config or form state.
✅ Solution 3 — Real-world patterns
The Record patterns you'll see (and write) every day.
These patterns cover 90% of Record usage in production. Each one is a standard idiom — learn them once and you'll recognize them in every codebase you work on.
// ─── Pattern 1: Index an array by ID ───────── interface User { id: string; name: string } function indexByIdextends { id: string }>(items: T[]): Record<string, T> { return Object.fromEntries(items.map(i => [i.id, i])); } const byId = indexById([ { id: '1', name: 'Alice' }, { id: '2', name: 'Bob' } ] as User[]); // ─── Pattern 2: Group by a key ─────────────── function groupBy extends string>(items: T[], keyFn: (item: T) => K): Record { const out: Record = {} as Record ; for (const item of items) { const k = keyFn(item); (out[k] ??= []).push(item); } return out; } interface Task { id: string; status: 'todo' | 'done' } const tasks: Task[] = [ { id: '1', status: 'todo' }, { id: '2', status: 'done' }, { id: '3', status: 'todo' } ]; const byStatus = groupBy(tasks, t => t.status); // { todo: Task[], done: Task[] } // ─── Pattern 3: Config map ──────────────────── type Env = 'development' | 'staging' | 'production'; type EnvConfig = { apiUrl: string; debug: boolean }; const config: Record = { development: { apiUrl: 'http://localhost:3000', debug: true }, staging: { apiUrl: 'https://staging.api.com', debug: true }, production: { apiUrl: 'https://api.example.com', debug: false } }; function getConfig(env: Env): EnvConfig { return config[env]; // typed by Env } // ─── Pattern 4: Handler dispatch table ──────── type EventName = 'click' | 'focus' | 'blur'; type Handler = (e: Event) => void; const handlers: Record = { click: (e) => console.log('click', e), focus: (e) => console.log('focus', e), blur: (e) => console.log('blur', e) }; function handle(name: EventName, event: Event): void { handlersname; // dispatch — no lookup errors } // ─── Pattern 5: Assertion maps ──────────────── type Format = 'json' | 'xml' | 'csv'; const contentTypes: Record string> = { json: 'application/json', xml: 'application/xml', csv: 'text/csv' }; // ─── Pattern 6: Exhaustiveness helper ──────── function describe(status: 'idle' | 'loading' | 'done'): string { const descriptions: Record<typeof status, string> = { idle: 'Ready', loading: 'Loading…', done: 'Complete' }; return descriptions[status]; } console.log(byId, byStatus); console.log(getConfig('production')); console.log(contentTypes.json, describe('done'));
function indexById(items) { return Object.fromEntries(items.map(i => [i.id, i])); } const byId = indexById([ { id: '1', name: 'Alice' }, { id: '2', name: 'Bob' } ]); function groupBy(items, keyFn) { const out = {}; for (const item of items) { const k = keyFn(item); (out[k] ??= []).push(item); } return out; } const tasks = [ { id: '1', status: 'todo' }, { id: '2', status: 'done' }, { id: '3', status: 'todo' } ]; const byStatus = groupBy(tasks, t => t.status); const config = { development: { apiUrl: 'http://localhost:3000', debug: true }, staging: { apiUrl: 'https://staging.api.com', debug: true }, production: { apiUrl: 'https://api.example.com', debug: false } }; function getConfig(env) { return config[env]; } const handlers = { click: (e) => console.log('click', e), focus: (e) => console.log('focus', e), blur: (e) => console.log('blur', e) }; function handle(name, event) { handlersname; } const contentTypes = { json: 'application/json', xml: 'application/xml', csv: 'text/csv' }; function describe(status) { const descriptions = { idle: 'Ready', loading: 'Loading…', done: 'Complete' }; return descriptions[status]; } console.log(byId, byStatus); console.log(getConfig('production')); console.log(contentTypes.json, describe('done'));
Index by ID
Record from an array. O(1) lookup. Object.fromEntries(items.map(...)) is the one-liner.
Group by key
Record — bucket items by a property. The generic signature preserves the exact key type.
Config maps
Record — one config per environment, all validated. No missing configs at runtime.
Dispatch tables
Record — map events to handlers. No switch, no lookup errors. The pattern behind reducers and event buses.
⚠️ Common mistakes
The Record bugs that cost real time.
Record allows any string key. Fix: Record<'a'|'b'|'c', number> — constrain the keys.
Record<'a'|'b', T> requires both keys. Fix: Partial for a partial dictionary.
JS coerces numeric keys to strings. Record works but behaves like Record. Fix: Map for real numeric keys.
Writing { [env: string]: Config } for a fixed Env union. Fix: Record — enforced exhaustiveness.
{ a: 1 } as Record<'a'|'b', number> — hides missing b. Fix: use Partial if keys are optional.
for (const k of obj) fails — objects aren't iterable. Fix: Object.entries(obj), or use Map if iteration is central.
Record is a type — the runtime object preserves key order per JS rules (numeric keys sorted, strings in insertion order). Fix: use Map for guaranteed iteration order.
Record is best for immutable config. For collections that grow, shrink, or need .size, .get, .delete, use Map.
❓ Frequently asked
What is the Record type in TypeScript?
Record
What is the difference between Record and an 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 the exact union — autocomplete works, and using an unknown key is a compile error. Record also enforces that all keys in the union are present.
Can Record keys be numbers or symbols?
Yes. Record
Why is my Record missing properties?
If K is a union of literals like 'a' | 'b' | 'c', Record enforces that ALL keys are present. A partial object fails. To allow missing keys, use Partial