TypeScript Dictionary

September 30, 20269 min read
Requirements:
ObjectsGenerics| Unions

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:

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 fine

That 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 undefined

The 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 | undefined

Three 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 signatureRecord<K, V>Map<K, V>
Key typestring / number / symbolAny union of keysAnything, including objects
Keys known up frontNoYesNo
Missing key is typedOnly with noUncheckedIndexedAccessSameAlways | undefined
Entry countObject.keys().lengthObject.keys().length.size
Delete an entrydelete operatordelete operator.delete()
Survives JSON.stringifyYesYesNo
Insertion order guaranteedNo (integer-like keys sort first)NoYes

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 optional

Partial<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 there

unsafeBag['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
//    ^? false

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

Share this article

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

Practice with Challenges

Put your typescript dictionary knowledge to the test with these related challenges.

#1367Remove Index Signature
Medium
#11Tuple to Object
Easy
#34857Defined Partial Record
Medium
#4Pick
Easy

Related Concepts

Concepts that build on or relate to typescript dictionary.

TypeScript RecordMapped TypesObject.entries in TypeScriptUnion TypesInterfaces