Records & Index Signatures
Reviewed & published by Brayan K
An index signature ({ [k: string]: V }) types an object as a dictionary with any number of keys, while Record<K, V> is the built-in utility for an object whose keys are K and values are V — both are how you give types to key-value maps.
Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Type open dictionaries with [key: string] index signatures
- Use Record<K, V> for fixed or open key-value objects
- Tell Record<union, V> (fixed keys) from Record<string, V> (open)
- Extract a key union with keyof
- Why noUncheckedIndexedAccess makes lookups V | undefined
- Default safely when a dictionary key is missing
💡 Real-World Analogy
Think of a hotel's room-key board. An index signature is a board with unlimited unlabeled hooks: any room number can hang a key (any string key, any value). A Record<union, V> is a board pre-printed with exactly the rooms that exist — every slot is named and must be filled. And noUncheckedIndexedAccess is the honest sign reminding you that reaching for a room that doesn't exist gets you an empty hook (undefined), not a key.
1. Index Signatures: Open Dictionaries
When you don't know the keys in advance — a scores-by-name map, feature flags, a cache — an index signature types it. { [name: string]: number } means "any string key maps to a number." You can read and write any key, iterate with Object.entries, and you can even combine an index signature with a few known fixed properties, as long as their types are compatible.
// An index signature: "any string key maps to a number"
interface Scores {
[name: string]: number;
}
const scores: Scores = {};
scores["ada"] = 95; // ✅
scores.bo = 88; // ✅ dot access works too
// scores["x"] = "hi"; // ❌ string is not assignable to number
// The key type can be string, number, or symbol. You may also mix in known
// members, as long as they're compatible with the index signature's value:
interface Config {
version: number; // a fixed, known property
[flag: string]: number; // plus any number of extra numeric flags
}// An INDEX SIGNATURE lets an object have ANY number of keys of a given type.
// In TS: interface Scores { [name: string]: number }
// meaning "any string key maps to a number". It's how you type a dictionary.
// At runtime it's an ordinary object used as a key->value map.
const scores = {}; // typed: { [name: string]: number }
scores["ada"] = 95;
scores["bo"] = 88;
console.log(scores["ada"]); // 95
console.log(scores["bo"]); // 88
// You can iterate the dynamic keys with Object.entries:
for (const [name, score] of Object.entries(scores)) {
console.log(name + ": " + score);
}
// ada: 95
// bo: 88
// Looking up a missing key returns undefined at runtime:
console.log(scores["nobody"]); // undefined2. Record<K, V> and keyof
Record<K, V> builds a key-value object type in one expression. With a union of literal keys — Record<"admin" | "user", boolean> — every listed key is required, making it ideal for fixed maps. With Record<string, V> it behaves like an open index signature. The keyof operator pulls the key union back out of any object type.
// Record<Keys, Value> builds an object type in one line:
type Roles = Record<"admin" | "user" | "guest", boolean>;
// { admin: boolean; user: boolean; guest: boolean } — ALL keys required
const roles: Roles = { admin: true, user: false, guest: false };
// Record<string, V> behaves like an open index signature (any string key):
type Prices = Record<string, number>;
const prices: Prices = { apple: 1.2, pear: 0.9 };
// 'keyof' extracts the key union from any object type:
type FixedKeys = keyof Roles; // "admin" | "user" | "guest"
// Index signature vs Record<union, V>:
// { [k: string]: V } -> ANY key allowed, none required
// Record<"a" | "b", V> -> EXACTLY those keys, all required// Record<K, V> is the built-in utility for "an object whose keys are K and
// values are V". In TS: type Roles = Record<"admin" | "user", boolean>;
// -> { admin: boolean; user: boolean } (a FIXED set of keys)
// vs an index signature, which allows ANY key of that type.
// At runtime both are just plain objects.
// Record with a fixed union of keys = every key MUST be present in TS:
const roles = { admin: true, user: false }; // Record<"admin"|"user", boolean>
console.log(roles.admin); // true
console.log(roles.user); // false
// Record<string, V> is like an index signature — an open dictionary:
const prices = { apple: 1.2, pear: 0.9 }; // Record<string, number>
console.log(prices.apple + prices.pear); // 2.1
// 'keyof' on a record type gives the union of its keys. In TS:
// type Fruit = keyof typeof prices; // "apple" | "pear"
console.log(Object.keys(prices).join(", ")); // apple, pear3. noUncheckedIndexedAccess: Honest Lookups
By default, TypeScript optimistically types a dictionary lookup as the value type — even though, at runtime, a missing key is undefined. That gap causes real "cannot read property of undefined" crashes. The noUncheckedIndexedAccess compiler flag closes it by typing every index access as V | undefined, forcing you to check or default first. It's strongly recommended whenever you work with dictionaries.
// WITHOUT noUncheckedIndexedAccess (the default) — TS trusts you too much:
const scores: Record<string, number> = { ada: 95 };
const n: number = scores["missing"]; // TS says number, runtime is undefined 💥
// WITH noUncheckedIndexedAccess ON (recommended for dictionaries):
// scores["missing"] has type number | undefined
// const n: number = scores["missing"]; // ❌ forces you to handle undefined
// Safe access becomes explicit — exactly the bug-prevention you want:
const value = scores["ada"];
if (value !== undefined) {
value.toFixed(2); // ✅ narrowed to number
}
// or: const v = scores["ada"] ?? 0;// noUncheckedIndexedAccess: a strictness flag that makes index/Record access
// return 'V | undefined' instead of just 'V'. In TS, with it ON:
// const n: number = scores["x"]; // ❌ could be undefined
// const n = scores["x"]; // type is number | undefined
// This forces you to handle the "key missing" case — matching JS reality.
const scores = { ada: 95 };
// At runtime, a missing key is ALWAYS undefined — the flag just makes the
// TYPE honest about it:
console.log(scores["ada"]); // 95
console.log(scores["missing"]); // undefined
// The safe pattern the flag nudges you toward — check or default:
function getScore(map, key) {
const value = map[key];
return value ?? 0; // default when the key is absent
}
console.log(getScore(scores, "ada")); // 95
console.log(getScore(scores, "missing")); // 0
// Calling a method on a possibly-undefined lookup is the classic crash:
// scores["missing"].toFixed(); // 💥 at runtime — the flag warns you in TS🎯 Your Turn
Build a word-count dictionary, defaulting a missing count to 0. Fill in the blank marked ___ with the right operator, then run it.
// 🎯 YOUR TURN — replace the ___ then press "Try it Yourself".
// Build a word-count dictionary: Record<string, number>.
// The first time we see a word its count is missing, so default to 0.
// Hint: use the nullish coalescing operator ??
function countWords(words) {
const counts = {}; // Record<string, number>
for (const w of words) {
counts[w] = (counts[w] ___ 0) + 1; // 👉 default missing counts to 0 with ??
}
return counts;
}
console.log(countWords(["a", "b", "a", "a"])); // { a: 3, b: 1 }
// ✅ Expected output:
// { a: 3, b: 1 }Common Errors (and the fix)
- ❌ "Cannot read properties of undefined" from a dictionary lookup. ✅ Enable noUncheckedIndexedAccess and default with ?? fallback before using the value.
- ❌ "Property 'admin' is missing" on a fixed Record<union, V>. ✅ Provide every key in the union, or switch to an open Record<string, V>.
- ❌ Mixing known fields with an incompatible index signature — TS requires the fixed property's type to be assignable to the index value type. ✅ Align the types or split into separate shapes.
- ❌ Iterating with for...in and hitting prototype keys. ✅ Use Object.keys / Object.entries, which only see own enumerable keys.
Pro Tips
- 💡 Use Record<Union, V> for exhaustive maps — a color-per-status or handler-per-event table where every key must be present.
- 💡 Turn on noUncheckedIndexedAccess for any code that reads dictionaries by dynamic keys — it eliminates a whole class of undefined crashes.
- 💡 Derive key unions with keyof typeof obj so a function only accepts keys that actually exist on a specific object.
- 💡 Reach for Map when keys aren't strings, order matters, or you delete entries often — objects shine for static, string-keyed data.
📚 Keep learning
- TypeScript Handbook — Index Signatures
- TypeScript Handbook — Record<Keys, Type>
- TSConfig Reference — noUncheckedIndexedAccess
Mini-Challenge: groupBy
No blanks this time — just a brief and a starting outline. Build the grouping function yourself, run it, and check your output against the example in the comments.
// 🎯 MINI-CHALLENGE: groupBy — the classic dictionary builder.
// In TS the result type is Record<string, T[]>.
//
// Write groupBy(items, keyFn) that:
// 1. Computes a key for each item with keyFn(item).
// 2. Pushes the item into groups[key], creating an empty array first time.
// (Tip: 'groups[key] ??= []' creates the array only if missing.)
// 3. Returns the groups object.
//
// Group the 'people' below by city.
//
// ✅ Expected output:
// 2
// 1
// Ada, Cy
const people = [
{ name: "Ada", city: "London" },
{ name: "Bo", city: "Paris" },
{ name: "Cy", city: "London" },
];
// your code here🎉 Lesson Complete
- ✅ An index signature { [k: string]: V } types an open dictionary
- ✅ Record<K, V> builds key-value object types concisely
- ✅ Record<union, V> requires all keys; Record<string, V> is open
- ✅ keyof extracts the union of an object type's keys
- ✅ noUncheckedIndexedAccess makes lookups V | undefined — handle the gap
Practice quiz
What does the index signature '{ [name: string]: number }' describe?
- An object with exactly one key
- An array of numbers
- An object where any string key maps to a number
- A number named 'name'
Answer: An object where any string key maps to a number. An index signature types an open dictionary: any string key, all values numbers.
What does Record<'admin' | 'user', boolean> require?
- Exactly the keys admin and user, both present
- Any string keys
- At most two keys
- Only the admin key
Answer: Exactly the keys admin and user, both present. A Record over a union of literal keys requires every listed key to be present.
How does Record<string, number> compare to an index signature?
- It is stricter
- It requires all keys
- It forbids dot access
- It behaves like an open index signature - any string key
Answer: It behaves like an open index signature - any string key. Record<string, V> is an open dictionary, just like { [k: string]: V }.
What does 'keyof' produce when applied to an object type?
- The value types
- A union of the object's keys
- The number of keys
- A new object
Answer: A union of the object's keys. keyof Roles for Record<'admin'|'user', boolean> gives 'admin' | 'user'.
With noUncheckedIndexedAccess on, what is the type of a dictionary lookup?
- V | undefined
- V
- unknown
- never
Answer: V | undefined. The flag makes index access return V | undefined, matching runtime reality.
At runtime, looking up a missing key in an object returns what?
- null
- an error
- undefined
- an empty object
Answer: undefined. A missing key is always undefined at runtime; the flag makes the type honest.
What is a safe way to read a possibly-missing dictionary value?
- map[key].toFixed()
- map[key] ?? defaultValue
- map[key]!
- delete map[key]
Answer: map[key] ?? defaultValue. Use ?? to provide a default when the key is absent.
Can you combine a known property with an index signature?
- No, never
- Only for number keys
- Only inside a class
- Yes, if the known property's type is compatible with the index value type
Answer: Yes, if the known property's type is compatible with the index value type. A fixed property is allowed as long as it's assignable to the index signature's value type.
When should you prefer a Map over a typed object dictionary?
- Always
- When keys aren't strings, order matters, or you delete entries often
- For JSON config
- For string-keyed lookup tables
Answer: When keys aren't strings, order matters, or you delete entries often. Map shines for non-string keys, ordering, frequent inserts/deletes, and a clean size/iteration API.
Which key types are allowed in an index signature?
- Only string
- Only number
- string, number, or symbol
- Any type
Answer: string, number, or symbol. Index signature keys can be string, number, or symbol.
Continue this course
- Previous: readonly, as const & Immutability
- Next: Template Literal Types — Build string types from other types — event names, key prefixes, and more
- Quick reference: TypeScript cheat sheet
Frequently asked questions
What's the difference between an index signature and Record?
They overlap heavily. An index signature like { [k: string]: number } is written inline on an interface or type and says 'any string key maps to a number, none required.' Record<K, V> is a utility that produces the same kind of object type more concisely, and crucially it can take a UNION of literal keys — Record<'admin' | 'user', boolean> — which requires EXACTLY those keys, all present. Use an index signature (or Record<string, V>) for open dictionaries, and Record<union, V> for a fixed, known set of keys.
Why does my dictionary lookup say the value can't be undefined when it clearly can?
By default TypeScript assumes every index access succeeds, so dict['missing'] is typed as the value type even though at runtime it's undefined. That mismatch causes real crashes. Turning on the noUncheckedIndexedAccess compiler flag makes index and Record access return 'V | undefined', forcing you to check or default before use — which matches how JavaScript actually behaves.
What does keyof do on a record type?
keyof takes an object type and produces a union of its keys. For Record<'admin' | 'user', boolean>, keyof gives 'admin' | 'user'. Combined with 'typeof' on a value (keyof typeof prices), it lets you derive a key union directly from a real object, which is handy for writing functions that only accept valid keys of a specific dictionary.
Should I use a plain object or a Map for a dictionary?
A typed object (index signature or Record) is great for fixed or string-keyed data, especially config and lookup tables, and it serializes to JSON naturally. A Map is better when keys aren't strings, when you insert and delete frequently, when key order matters, or when you want a clean .size and iteration API without prototype-key pitfalls. For most typed dictionaries an object is the simpler default; reach for Map when its features earn their keep.