Record<string, number> and { [key: string]: number } are the same type written two ways — Record is a mapped type built on the index signature. The trap in both is that TypeScript assumes every lookup succeeds: counts['missing'] is typed number even though it is undefined at runtime. noUncheckedIndexedAccess in tsconfig.json is the single most valuable setting for anyone working with dictionaries.
That default is a deliberate ergonomic trade-off by the TypeScript team, and it is the source of most dictionary-shaped bugs. Everything below assumes you would rather have the safety.
Table of contents
- Record and index signatures
- The missing-key problem
- Narrowing keys with in and hasOwnProperty
- Partial, and typing a lookup that may miss
- When to use Map instead
- Building dictionaries with the right type
- How this fits the rest of the stack
- FAQ
Record and index signatures
// These are equivalent
type Counts = Record<string, number>;
type Counts = { [key: string]: number };
// Record's real value is a constrained key set
type Role = 'admin' | 'member' | 'viewer';
type Permissions = Record<Role, string[]>;
// Now every role must be present -- omitting one is an error
const perms: Permissions = {
admin: ['read', 'write', 'delete'],
member: ['read', 'write'],
viewer: ['read'],
};
That exhaustiveness is the reason to prefer Record when the keys are known. Add a fourth role to the union and every Record<Role, ...> in the codebase becomes a compile error listing exactly what needs updating — which is how you want that change to go.
For an open-ended dictionary the two forms are interchangeable. The index signature form is required when you want extra known properties alongside the open set:
interface Config {
name: string; // known
[key: string]: string | number; // plus anything else
}
Note the constraint: every declared property must be assignable to the index signature’s value type. Adding enabled: boolean to the above is an error until boolean joins the union.
The missing-key problem
const counts: Record<string, number> = { apples: 3 };
const n = counts['bananas']; // typed number
console.log(n.toFixed(2)); // TypeError at runtime -- n is undefined
TypeScript is confidently wrong here, and it compiles without complaint. The fix is one compiler option:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}
With it on, counts['bananas'] is number | undefined and the compiler makes you handle it:
const n = counts['bananas'];
if (n !== undefined) {
console.log(n.toFixed(2));
}
// or
console.log((counts['bananas'] ?? 0).toFixed(2));
It is not part of strict, so you have to opt in explicitly. It produces a wave of new errors in an existing codebase, and essentially every one of them is a place that would have thrown at runtime given the wrong input. Enabling it on a mature project is a genuinely good afternoon’s work.
Narrowing keys with in and hasOwnProperty
function get(counts: Record<string, number>, key: string) {
if (key in counts) {
return counts[key]; // narrowed to number
}
return 0;
}
// Object.hasOwn ignores the prototype chain -- prefer it
if (Object.hasOwn(counts, key)) {
return counts[key];
}
in walks the prototype chain, so 'toString' in {} is true. Object.hasOwn checks own properties only and is the correct test for a dictionary. This matters when keys come from user input: a key of constructor or __proto__ finds something that is not your data.
For a dictionary with no prototype at all, Object.create(null) gives you a clean object where the whole question disappears.
Partial, and typing a lookup that may miss
When the keys are a known union but not all of them will be present, Record alone is too strict and Record<string, T> is too loose.
type Role = 'admin' | 'member' | 'viewer';
// All keys required
const full: Record<Role, string[]> = { admin: [], member: [], viewer: [] };
// Any subset, values possibly undefined
const partial: Partial<Record<Role, string[]>> = { admin: ['read'] };
// Keys restricted to the union, but arbitrary string lookups rejected
function can(role: Role, action: string) {
return (full[role] ?? []).includes(action);
}
Partial<Record<K, V>> is the honest type for a lookup table that may not cover every case, and it forces the ?? default at every use — which is the behaviour you wanted.
When to use Map instead
Objects are not the only option, and for several cases Map is straightforwardly better.
- Non-string keys. Object keys are coerced to strings, so
obj[1]andobj['1']are the same slot.Mapkeeps1and'1'distinct and accepts objects as keys. - Frequent additions and deletions.
Mapis optimised for it;deleteon an object can deoptimise its shape. - Insertion order with numeric-looking keys. Object keys that look like integers are ordered numerically first, regardless of insertion order.
Mapalways preserves insertion order. - Size.
map.sizeis a property; object size requiresObject.keys(o).length. - Keys that might collide with prototype properties.
Maphas no prototype chain issue at all.
const counts = new Map<string, number>();
counts.set('apples', 3);
const n = counts.get('bananas'); // number | undefined, always
// Increment safely
counts.set('apples', (counts.get('apples') ?? 0) + 1);
Map.get returns T | undefined regardless of compiler flags, which makes it honest by default. Its main downside is that it does not serialise to JSON — JSON.stringify(map) gives {}, and you need Object.fromEntries(map) first.
Rough rule: object or Record for a fixed, known set of string keys, especially anything crossing a JSON boundary. Map for a dynamic collection that grows and shrinks at runtime.
Building dictionaries with the right type
const users = [
{ id: 'u1', name: 'Ada' },
{ id: 'u2', name: 'Lin' },
];
// Index by id
const byId: Record<string, typeof users[number]> =
Object.fromEntries(users.map(u => [u.id, u]));
// Group by a key
function groupBy<T, K extends string>(items: T[], key: (item: T) => K) {
const out = {} as Partial<Record<K, T[]>>;
for (const item of items) {
const k = key(item);
(out[k] ??= []).push(item);
}
return out;
}
Object.groupBy now exists natively and returns a Partial<Record<K, T[]>> shape for exactly this reason — the standard library agrees that a grouped lookup can miss.
One caution on Object.fromEntries: it happily produces an object typed as covering every string key, when in reality it covers exactly the ids in the array. That is the same optimism as the index signature, and noUncheckedIndexedAccess is again what makes it honest.
This matters most where the dictionary is built from data you did not write — a database result set, a config file, an API response. The compiler cannot know which keys are present because they are decided at runtime, so it defers to you, and the flag decides whether you get asked.
How this fits the rest of the stack
Record<K, V> when the keys are a known union and you want exhaustiveness. An index signature when the object has known properties plus an open set. Partial<Record<K, V>> when a known key set may be incompletely filled. Map for dynamic collections and non-string keys.
Above all, turn on noUncheckedIndexedAccess. It is not part of strict, it will produce a lot of errors on first run, and nearly every one is a real crash you have not had yet. If you are working out what the service and database behind that code cost to run, the RunxBuild hosting calculator lists them separately.
Useful related references:
- Sort a Python Dictionary by Value: sorted, a Lambda, and What You Get Back
- SyntaxError Unexpected Token: The Five Things It Actually Means in JavaScript and TypeScript
- Services on RunxBuild
FAQ
What is the difference between Record and an index signature in TypeScript?
They describe the same thing — Record<K, V> is a mapped type built on index signatures. Record is better when the keys are a known union, because it requires every key to be present. An index signature is required when you want specific named properties alongside an open-ended set of keys.
Why does TypeScript not warn about missing dictionary keys?
By default TypeScript assumes an index lookup succeeds, so counts['missing'] is typed as the value type rather than value | undefined. It is a deliberate ergonomic choice. Enable noUncheckedIndexedAccess in tsconfig.json to make lookups include undefined and force you to handle the miss.
Should I use a Map or an object for a dictionary in TypeScript?
Use Map for dynamic collections, non-string keys, frequent insertion and deletion, or when you need reliable insertion order. Use an object or Record for a fixed set of string keys, especially anything that has to serialise to JSON, since JSON.stringify on a Map produces an empty object.
How do I safely check whether a key exists in a TypeScript object?
Use Object.hasOwn(obj, key) rather than key in obj. The in operator walks the prototype chain, so 'toString' in {} is true, which matters when keys come from user input. Object.hasOwn also narrows the type for subsequent access.
How do I type a lookup table that might not cover every case?
Partial<Record<Key, Value>>. The keys stay restricted to your union so typos are caught, but the values are possibly undefined, which forces a default at each use site. This is the same shape the native Object.groupBy returns, for the same reason.