Problem Solved

TypeScript · Record

Home › Courses › TypeScript › Problems › Record
🔷 Problem · 5 min read

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.

record-known.ts
// ─── 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 = RecordRecordstring>>;

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: Recordstring> = {
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.

record-string.ts
// ─── Record vs 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.

real-world.ts
// ─── 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 groupByextends 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: Recordstring> = {
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.

1. Using Record when keys are known

Record allows any string key. Fix: Record<'a'|'b'|'c', number> — constrain the keys.

2. Forgetting Partial when keys are optional

Record<'a'|'b', T> requires both keys. Fix: Partial> for a partial dictionary.

3. Using Record for true numeric keys

JS coerces numeric keys to strings. Record works but behaves like Record. Fix: Map for real numeric keys.

4. Not using Record for config maps

Writing { [env: string]: Config } for a fixed Env union. Fix: Record — enforced exhaustiveness.

5. Casting partial object to Record

{ a: 1 } as Record<'a'|'b', number> — hides missing b. Fix: use Partial> if keys are optional.

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

for (const k of obj) fails — objects aren't iterable. Fix: Object.entries(obj), or use Map if iteration is central.

7. Assuming Record preserves insertion order

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.

8. Using Record instead of Map for mutable collections

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 builds an object type where every key is of type K and every value is of type V. For example, Record<'admin' | 'user' | 'guest', boolean> produces { admin: boolean; user: boolean; guest: boolean }. It's the cleanest way to build typed dictionaries and lookup tables.

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 allows any number key. Record allows symbol keys. However, JavaScript object keys are always coerced to strings at runtime — Record is really Record<'1' | '2' | '3', string> under the hood. For true numeric keys, use Map.

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>. To allow extra keys, use Record. Record is strict by design — that's what makes it safer than an index signature.

🔗 Related

← All TypeScript problems
Problem solved · TypeScript