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 typeKand values of typeV. - noUncheckedIndexedAccess
- A compiler flag that adds
| undefinedto 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 ofobj.keyfor 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
toStringandconstructorcome from. - Key type
- The type before the colon in an index signature:
string,number,symbolor 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.
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.
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.
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.
{
"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.
Try it yourself
- 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:countreturnsnumber | undefined, and?? 0replaces theundefinedwith a number.The error disappears because you handled
undefinedbefore using the number. - 2
Add a day
In the second example change
Dayto include"thu". Predict which lines change colour.hoursnow missesthu,partialstill misses two keys, and the error onextradisappears becausethuis a known key. - 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 thez. Read the hover. What doesMap.getreturn when a key is missing? The panel saysconst z: number | undefined: always, with no flag needed.
Code & diagrams
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')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 }
21This 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
localhostNo 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 }
3Break 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.
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 }.
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 inlib.es5.d.tsastype Record<K extends keyof any, T> = { [P in K]: T }, a mapped type (Topic 8.4). WithK = stringthe mapped type collapses to an index signature. The practical difference:Record<Day, number>has real, named properties, sohours.monis a normal property access with noundefined, even undernoUncheckedIndexedAccess. - ▸
Key types:
numberindex 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 addedsymboland template-literal pattern signatures such as[key:data-${string}]: string. - ▸
Object.entries(dict)is typed[string, T][]andObject.keys(dict)asstring[]. That is correct for dictionaries but for aRecord<Day, number>it loses the union. If you want the keys back asDay[]you need a cast, a known unsoundness trade-off.
Remember this
- 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 astring, and each value is anumber. The wordkeyis only a label for readers. The key type can bestring,number,symbolor a template literal pattern;stringis by far the most common. Use it for dictionaries: word counts, price lists, caches, settings loaded from a file. - 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 getProperty 'wed' is missing; add an unknown one in a fresh literal and you getTS2353. That makesRecordover 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
Reading a key that might not exist.
stock[item]is typednumber, but at runtime a missing key givesundefined. TypeScript's default is to trust the signature, which is a known hole. Turn on **noUncheckedIndexedAccess** (it is in thetsc --initset and on in every example here) and the type becomesnumber | undefined, so you have to handle the missing case. The first example shows the hover and theTS2532error it prevents. Some tools say "possibly undefined" for index access, and that is this flag working. - 4
**
noPropertyAccessFromIndexSignature** (TypeScript 4.2) is a style flag for dictionaries. When on,config.hostis an error (TS4111) ifhostis only known through an index signature, and you must writeconfig["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 ofstrict, so teams opt in. - 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), becauselabelis 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
**Dictionary or
Map?** An object used as a dictionary has a hidden trap: it already contains inherited keys likeconstructorandtoString. In the fourth example counting the word"constructor"reads the inherited function instead ofundefined, and the "number" ends up as the textfunction Object() { [native code] }1, while the type saysnumber. TypeScript cannot see this. AMap<string, number>has no such problem, keeps insertion order, accepts any key type, and has a propersize. UseMapfor arbitrary keys (user input!), and objects for fixed, known shapes or JSON-like data. See the JavaScript course on objects andMap.
Explain it without notes
What is an index signature, and what is Record<K, V>?
Why can reading from a Record<string, number> be unsafe, and what fixes it?
What does noPropertyAccessFromIndexSignature do?
When would you use a Map instead of an object typed with an index signature?
Practice
Write a function countLetters(text: string) that returns how many times each letter appears, using a Map<string, number>. Log the result for "banana".
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. UsenoUncheckedIndexedAccessand defaults, or move to aMap. - ↔
Recordover a union makes tables safe to extend but ties them to the union. Overstringit 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
Recordover a union to require a value for every case.I can explain what
noUncheckedIndexedAccessdoes to a lookup.I can explain
TS2411andTS4111.I can explain why an object keyed by user text can break on
"constructor"and when to use aMap.