TypeScript Dictionary
There is no Dictionary type in TypeScript. If you came from Python's dict, C#'s
Dictionary<K, V> or Java's HashMap and went looking for the equivalent, that is the
first thing to know — a TypeScript dictionary is a pattern, not a built-in.
What you get instead is three ways to model one: an index signature, Record<K, V>, and
the Map object. They are not interchangeable. Picking the wrong one is how you end up
with a lookup that the compiler swears returns a string and that hands you undefined
at runtime.
This page covers all three, when each is the right call, and the gotchas that only show up once real data is flowing through.
The three ways to build a TypeScript dictionary
A quick orientation before the details:
- Index signature —
{ [key: string]: T }. Any string key is allowed. Loosest, and the one you reach for when the keys are genuinely unknown. Record<K, V>— a closed set of keys, each mapping toV. Best when you know the keys at compile time.Map<K, V>— a real runtime data structure. Keys do not have to be strings, and it has.size,.delete()and a defined iteration order.
The first two are plain objects with types on top. The third is a different object entirely.
Option 1: the index signature
An index signature says "any key of this type maps to a value of that type".
type ScoreTable = { [key: string]: number }
const scores: ScoreTable = {
alice: 12,
bob: 9,
}
scores.carol = 15 // ✅ any string key is fineThat flexibility is the point. You use it when the keys come from user input, a network response or a config file — anywhere you cannot enumerate them in advance.
It also has one failure mode worth internalising. By default, TypeScript types every lookup as the value type, whether or not the key exists:
const danScore = scores.dan
// ^? number — but at runtime this is undefinedThe compiler is not lying so much as being optimistic. scores.dan is typed number, you
do arithmetic on it, and you get NaN. This is the single most common source of runtime
surprises in dictionary-shaped code, and it is invisible in review because the types all
line up.
The fix is the noUncheckedIndexedAccess compiler option. Switch it on and every index
lookup becomes number | undefined, which forces you to handle the miss:
function readScore(table: Record<string, number>, name: string): number {
const found: number | undefined = table[name]
return found ?? 0
}Writing the | undefined by hand, as above, gets you the same safety without the flag.
It is more honest about what an index lookup actually returns, and it is the habit worth
building whether or not the flag is on.
If you want the reverse operation — stripping an index signature back off a type — the Remove Index Signature challenge is good practice.
Option 2: Record when you know the keys
When the key set is finite and known, a union of string literals turns an open dictionary into a closed one:
type FeatureFlag = 'darkMode' | 'betaSearch' | 'newEditor'
const flags: Record<FeatureFlag, boolean> = {
darkMode: true,
betaSearch: false,
newEditor: true,
}Now a typo is a compile error, a missing key is a compile error, and adding a fourth flag to the union breaks every object built from it until you fill the gap. The dictionary stops being a bag of values and starts being a checklist.
That property comes from union types doing the work — the
literal union is what makes the key set closed. Record itself is a
mapped type underneath, which is why it can walk those keys and
build the object shape from them.
Record has enough depth to deserve its own page, so this one stays shallow on purpose:
TypeScript Record covers exhaustiveness, the lookup
gotchas and the Partial interaction in full.
One note that matters here: Record<string, T> and { [key: string]: T } are the same
thing. If your keys are open-ended, Record<string, T> is just a shorter index signature
and inherits the same missing-key optimism.
Option 3: Map for everything else
Map is not a typing trick, it is a runtime structure. Reach for it when a plain object
cannot do the job:
type UserId = number
const lastSeenById = new Map<UserId, string>()
lastSeenById.set(101, '2026-09-30T09:12:00Z')
lastSeenById.set(102, '2026-09-29T18:04:00Z')
const lastSeen = lastSeenById.get(101)
// ^? string | undefinedThree things happen here that an object cannot match.
The key is a number and stays a number. Object keys are coerced to strings, so
obj[101] and obj['101'] are the same slot. A Map keeps them apart, and the key can
be an object or a symbol too.
.get() returns string | undefined with no compiler flag required. The API is honest
about misses because the type signature says so.
And .size is a real property. Counting the entries in an object means
Object.keys(obj).length, which allocates an array to answer a question about length.
The trade-off is serialisation. JSON.stringify(lastSeenById) gives you {} — a Map
does not survive a round trip through JSON without conversion. If the dictionary crosses
an API boundary, that alone usually decides it.
Which one to pick
| Index signature | Record<K, V> | Map<K, V> | |
|---|---|---|---|
| Key type | string / number / symbol | Any union of keys | Anything, including objects |
| Keys known up front | No | Yes | No |
| Missing key is typed | Only with noUncheckedIndexedAccess | Same | Always | undefined |
| Entry count | Object.keys().length | Object.keys().length | .size |
| Delete an entry | delete operator | delete operator | .delete() |
Survives JSON.stringify | Yes | Yes | No |
| Insertion order guaranteed | No (integer-like keys sort first) | No | Yes |
The short version: known keys → Record. Unknown string keys that go over the wire →
index signature. Everything else, especially non-string keys or heavy add/delete traffic →
Map.
Two of those rows do most of the deciding in practice. If the dictionary has to be
serialised — a response body, something written to localStorage, anything that hits
JSON.stringify — Map is out before you weigh anything else. And if you can write the
keys down as a union, Record is almost always worth it, because the compiler will then
tell you every place that needs updating the next time a key is added.
Iterating a dictionary
for...in is the obvious loop and the wrong default. It widens the key back to string,
throwing away the key type you just spent effort establishing:
const stock: Record<FeatureFlag, number> = {
darkMode: 3,
betaSearch: 0,
newEditor: 7,
}
for (const stockKey in stock) {
// stockKey is string, not FeatureFlag
console.log(stockKey)
}It also walks inherited enumerable properties, which is almost never what you want. The for...in behaviour has more on why the key widens.
Object.entries is the better tool — you get keys and values together:
for (const [entryKey, entryValue] of Object.entries(stock)) {
console.log(`${entryKey}: ${entryValue}`)
}The key still arrives as string there, because Object.entries is typed conservatively
for good reasons. Object.entries in TypeScript covers the
narrowing workarounds when you need the literal key type back.
A Map sidesteps all of it. Iteration preserves the key type with no cast:
for (const [mapKey, mapValue] of lastSeenById) {
// mapKey is UserId, mapValue is string
console.log(mapKey, mapValue)
}Adding, reading and deleting entries
Reading and writing an object dictionary is plain property access. Deleting is where the types get awkward:
const translationCache: Record<string, string> = {}
translationCache['welcome'] = 'Willkommen'
delete translationCache['welcome']That works because the keys are open. Try it on a closed Record and the compiler stops
you — correctly, since deleting a required key would leave the object lying about its own
shape:
const partialFlags: Partial<Record<FeatureFlag, boolean>> = {
darkMode: true,
}
delete partialFlags.darkMode // ✅ allowed, the key is optionalPartial<Record<K, V>> is the type for "these are the only valid keys, but any of them
may be absent". It is the closed-key dictionary you can actually delete from.
Gotchas
Optional values are not optional keys. Record<FeatureFlag, boolean | undefined>
requires every key to be present, even if the value is undefined.
Partial<Record<FeatureFlag, boolean>> makes the keys themselves optional. They read
similarly and behave differently:
const mustListAll: Record<FeatureFlag, boolean | undefined> = {
darkMode: true,
betaSearch: undefined,
newEditor: undefined, // ❌ removing this line is an error
}Prototype keys leak. Every object literal inherits from Object.prototype, so a
dictionary built from untrusted keys has surprises in it:
const unsafeBag: Record<string, string> = {}
const inherited = 'constructor' in unsafeBag
// ^? true — nobody put it thereunsafeBag['constructor'] is typed string and returns a function. If the keys come from
user input, use Object.create(null) for a prototype-less object, or use a Map, which
has no such inheritance:
const cleanBag: Record<string, string> = Object.create(null)
const hasConstructor = 'constructor' in cleanBag
// ^? falseInteger-like string keys reorder themselves. Object keys that look like array indices
are iterated first, in ascending numeric order, regardless of insertion order. If order
matters, Map is the only one of the three that guarantees it.
The takeaway
A TypeScript dictionary is a choice, not a type. Known keys get Record and the
compile-time checklist that comes with it. Unknown string keys that have to serialise get
an index signature, with | undefined written in by hand or noUncheckedIndexedAccess
turned on. Non-string keys, ordering guarantees or frequent deletes get a Map.
The mistake worth avoiding is reaching for an index signature by default. It is the loosest of the three, and the default compiler settings will happily tell you a missing key holds a value.
Become a TypeScript Pro
Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges