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

💡 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"]);     // undefined

2. 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, pear

3. 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)

Pro Tips

📚 Keep learning

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

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

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.