Command Palette

Search for a command to run...

Hectal
PHASE 3Beginner ~21 min· topic 6 of 6

Topic 3.6

Index Signatures and Records

In one line

When an object is a dictionary, with keys you don't know in advance, you describe it with an index signature [key: string]: T or the shorthand Record<K, V>. Reading a missing key gives undefined, and a safer tool, Map, exists for when keys are arbitrary.

Think of it like this

A school register where each line is a student's name and their mark. You cannot print the register in advance with fixed boxes ("Asha's mark", "Ravi's mark") because you don't know the names until students join. You only know the rule: any name goes on the left, a number goes on the right. That rule is an index signature.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Index signature
A rule for all keys of an object, written [key: string]: Value, used when the keys are not known in advance.
Dictionary
An object used as a lookup table from keys to values, like names to marks.
Record
A built-in shorthand: Record<K, V> is an object type with keys of type K and values of type V.
noUncheckedIndexedAccess
A compiler flag that adds | undefined to every value read through an index signature or array index, so you must handle a missing key.
noPropertyAccessFromIndexSignature
A compiler flag that forces obj["key"] instead of obj.key for keys known only through an index signature.
Map
A built-in JavaScript collection from keys to values. It accepts any key type and has no inherited keys to trip over.
Prototype
The hidden object every object inherits from. It is where toString and constructor come from.
Key type
The type before the colon in an index signature: string, number, symbol or a template literal pattern.

Step by step

01The rule for any key

Write the key in square brackets with its type, then the value type. Any string key is now allowed, and each value must be a number.

Assigning with dot syntax or with brackets both work, because the signature covers every key.

dictionary.tswhole filets
const stock: { [item: string]: number } = { apple: 10 };
stock.banana = 4;
stock["cherry"] = 7;

02Record, the shorthand

Record<string, number> is the same dictionary. With a union of exact texts as the key, Record demands exactly those keys.

That is a table with one row per case. If the union grows, every Record over it reports the new missing row.

record.tswhole filets
type Day = "mon" | "tue" | "wed";
const hours: Record<Day, number> = { mon: 8, tue: 6, wed: 7 };
const partial: Record<Day, number> = { mon: 8, tue: 6 };
// error TS2741: Property 'wed' is missing in type '{ mon: number; tue: number; }' but required in type 'Record<Day, number>'.

03A missing key is undefined

A dictionary read can miss. With noUncheckedIndexedAccess TypeScript adds | undefined to the result, and refuses to let you use it before checking.

Handle it with ?? for a default, or an if check.

lookup.tswhole filets
function count(item: string) {
  const n = stock[item];          // number | undefined
  return n ?? 0;                  // fine
}

04Dot access on a dictionary

noPropertyAccessFromIndexSignature makes lookups on dictionaries look different from reads of real properties.

It is a readability flag. It changes nothing at runtime.

tsconfig.jsonwhole filejson
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noPropertyAccessFromIndexSignature": true
  }
}

05When the keys are arbitrary, reach for Map

A plain object inherits keys. If the key comes from user input or text, a key like constructor collides with the inherited one.

Map stores only what you put in. Choose it for counting words, caching by IDs, or anything keyed by data you did not write yourself.

When the keys are arbitrary, reach for Mapdiagram
Rendering diagram…

Try it yourself

  1. 1

    Handle the missing value

    In the first example change the last line to console.log((count("durian") ?? 0).toFixed(1));. Predict the output before you run it.

    It prints 0.0: count returns number | undefined, and ?? 0 replaces the undefined with a number.

    The error disappears because you handled undefined before using the number.

  2. 2

    Add a day

    In the second example change Day to include "thu". Predict which lines change colour.

    hours now misses thu, partial still misses two keys, and the error on extra disappears because thu is a known key.

  3. 3

    Ask for a missing word

    At the end of the fourth example add const z = safe.get("zebra"); and, on the next line, // ^? with the ^ under the z. Read the hover. What does Map.get return when a key is missing? The panel says const z: number | undefined: always, with no flag needed.

Code & diagrams

A dictionary and the missing key

Because noUncheckedIndexedAccess is on, n is number | undefined and the last line is an error. It would crash at runtime exactly as the console shows. Without the flag the type would be a plain number and TypeScript would say nothing.

Console

(nothing was printed)

Expected console output

10 undefined
Uncaught TypeError: Cannot read properties of undefined (reading 'toFixed')
Record: a value for every key

A Record over a union of texts needs exactly those keys. A Record over string accepts any key. Add a fourth day to the union and watch hours fail too.

Console

(nothing was printed)

Expected console output

8 { asha: 91, ravi: 78 }
21
Dot access on a dictionary can be forbidden

This example turns on noPropertyAccessFromIndexSignature (it is off by default). The bracket form is accepted, the dot form is not, yet both print the same at runtime.

Console

(nothing was printed)

Expected console output

localhost
localhost
The word "constructor": object versus Map

No type error, and a wrong answer. counts["constructor"] found the function every object inherits, and the + glued text onto it. The type said number all along. The Map version gives the right count.

Console

(nothing was printed)

Expected console output

{ a: 2, b: 1, constructor: 'function Object() { [native code] }1' }
Map(3) { 'a' => 2, 'b' => 1, 'constructor' => 1 }
3

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Use a value that might not be there

Call .toFixed(1) on stock[item] where item is any string.

terminal
$ Types panel and Console
── what you'll see ──
index.ts(11,13): error TS2532: Object is possibly 'undefined'.
Uncaught TypeError: Cannot read properties of undefined (reading 'toFixed')

Break #2

Mix a text property into a number dictionary

At the end of the first example add interface Bad { with the two members [name: string]: number; and label: string; on separate lines, then }.

terminal
$ Types panel
── what you'll see ──
index.ts(14,3): error TS2411: Property 'label' of type 'string' is not assignable to 'string' index type 'number'.

Myth vs fact

Myth

Record<string, number> means every key I read exists.

Fact

It means every value that exists is a number. Reading an unknown key gives undefined at runtime, and only noUncheckedIndexedAccess makes the type say so.

Myth

Plain objects are safe dictionaries.

Fact

They inherit keys such as constructor, toString and __proto__. With keys from outside, use Map, or create the object with Object.create(null).

Myth

Record is a special runtime object.

Fact

It is an ordinary type alias for a mapped type. At runtime it is just an object.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Record<K, V> is defined in lib.es5.d.ts as type Record<K extends keyof any, T> = { [P in K]: T }, a mapped type (Topic 8.4). With K = string the mapped type collapses to an index signature. The practical difference: Record<Day, number> has real, named properties, so hours.mon is a normal property access with no undefined, even under noUncheckedIndexedAccess.

  • ▸

    Key types: number index signatures accept numeric-looking string keys too (obj["0"]), and the value type of a number signature must fit the string signature if both exist. TypeScript 4.4 added symbol and template-literal pattern signatures such as [key: data-${string}]: string.

  • ▸

    Object.entries(dict) is typed [string, T][] and Object.keys(dict) as string[]. That is correct for dictionaries but for a Record<Day, number> it loses the union. If you want the keys back as Day[] you need a cast, a known unsoundness trade-off.

Remember this

  1. 1

    The problem this solves: an interface needs every key written down in advance, and a dictionary's keys are only known when the program runs. An index signature looks like { [key: string]: number }. It says: this object may have any number of properties, each key is a string, and each value is a number. The word key is only a label for readers. The key type can be string, number, symbol or a template literal pattern; string is by far the most common. Use it for dictionaries: word counts, price lists, caches, settings loaded from a file.

  2. 2

    Record<K, V> is a built-in shorthand: Record<string, number> means the same as { [key: string]: number }. It becomes more useful when the keys are a known set: Record<"mon" | "tue" | "wed", number> means "an object with exactly these three keys, each a number". Leave one out and you get Property 'wed' is missing; add an unknown one in a fresh literal and you get TS2353. That makes Record over a union of literals a good tool for "a value for every case" tables, because adding a new case to the union forces you to update every table.

  3. 3

    Reading a key that might not exist. stock[item] is typed number, but at runtime a missing key gives undefined. TypeScript's default is to trust the signature, which is a known hole. Turn on **noUncheckedIndexedAccess** (it is in the tsc --init set and on in every example here) and the type becomes number | undefined, so you have to handle the missing case. The first example shows the hover and the TS2532 error it prevents. Some tools say "possibly undefined" for index access, and that is this flag working.

  4. 4

    **noPropertyAccessFromIndexSignature** (TypeScript 4.2) is a style flag for dictionaries. When on, config.host is an error (TS4111) if host is only known through an index signature, and you must write config["host"]. The square brackets are a visual signal: "this is a dictionary lookup, it could be missing". Declared properties still use dot access. It is off by default and not part of strict, so teams opt in.

  5. 5

    Index signatures impose a consistency rule: every explicit property in the same type must fit the index signature's value type. { [name: string]: number; label: string } is an error (TS2411), because label is a property too and "label" is a string key whose value must be a number. When you need mixed values, use a union (number | string) or split the fixed fields from the dictionary.

  6. 6

    **Dictionary or Map?** An object used as a dictionary has a hidden trap: it already contains inherited keys like constructor and toString. In the fourth example counting the word "constructor" reads the inherited function instead of undefined, and the "number" ends up as the text function Object() { [native code] }1, while the type says number. TypeScript cannot see this. A Map<string, number> has no such problem, keeps insertion order, accepts any key type, and has a proper size. Use Map for arbitrary keys (user input!), and objects for fixed, known shapes or JSON-like data. See the JavaScript course on objects and Map.

Explain it without notes

01

What is an index signature, and what is Record<K, V>?

02

Why can reading from a Record<string, number> be unsafe, and what fixes it?

03

What does noPropertyAccessFromIndexSignature do?

04

When would you use a Map instead of an object typed with an index signature?

Practice

01

Write a function countLetters(text: string) that returns how many times each letter appears, using a Map<string, number>. Log the result for "banana".

02

Declare a type Permission = "read" | "write" | "admin" and a Record<Permission, boolean> called rights for a guest user. Show what the compiler says when you forget admin.

Trade-offs

  • ↔

    Index signatures are quick but trust the data: a missing key is a runtime undefined. Use noUncheckedIndexedAccess and defaults, or move to a Map.

  • ↔

    Record over a union makes tables safe to extend but ties them to the union. Over string it behaves like an index signature with the same missing-key risk.

Done when you can

  • I can write an index signature and the equivalent Record<string, T>.

  • I can use Record over a union to require a value for every case.

  • I can explain what noUncheckedIndexedAccess does to a lookup.

  • I can explain TS2411 and TS4111.

  • I can explain why an object keyed by user text can break on "constructor" and when to use a Map.