Index signatures & Record
Type lookup tables and dictionaries whose keys you don't know ahead of time, with index signatures, Record, noUncheckedIndexedAccess, and Map.
- 01Type a lookup tableWrite
{ [key: string]: number }orRecord<string, number>for an object whose keys come from data. - 02Choose the keysUse
Recordwith a list of keys when every key must be there, and read the errors when one is missing or unknown. - 03Read missing keys safelyTurn on
noUncheckedIndexedAccess, handleundefined, and pick aMapwhen keys come from users.
Objects whose keys you don't know yet
A shop keeps a count of how many of each product is in stock. The keys are product IDs, and new products arrive all the time, so you can't list every key when you write the code. In JavaScript, you add and read keys with square brackets:
const stock = { tea: 3, jam: 5 };
stock["mint"] = 8;
const productId = "jam";
console.log(stock[productId]);Line 1 makes stock with two products. Line 2 adds a third, mint. Line 4 picks a product ID, and line 5 reads it with stock[productId], the square-bracket way to use a key held in a variable. It prints 5. (Object basics covers square brackets.)
An object type like { tea: number; jam: number } can't describe this, because it would need every name ahead of time. TypeScript has a type for "any key, but every value is a number":
type Stock = { [productId: string]: number }; const stock: Stock = { tea: 3, jam: 5 };stock["mint"] = 8;stock["honey"] = "none"; const count = stock["jam"];// ^?console.log(count);Line 1 is the new part. [productId: string]: number is an index signature. Read it as "for any key that is a string, the value is a number". productId is only a label for the key; any name works there. Then:
- Line 3 makes the stock, and line 4 adds
mint. Both are fine: any string key is allowed. - Line 5 stores the text
"none". TypeScript marks it, on purpose:Type 'string' is not assignable to type 'number'means "the value must be a number, and this is text". - Line 7 reads
jam. The query on line 8 showsconst count: number, and line 9 prints5.
tea, jam, or mint. It gives one rule: any string key, holding a number. Line 4 adds mint, and the rule covers it too.At a theatre cloakroom, the attendant doesn't know in advance whose coats will come in. There's one rule: each numbered hook holds one coat. You hand in a coat and get a ticket. Ask for a ticket number that nobody handed in, and the hook is empty.
- In real life: A cloakroom with a hook for every ticket
- In TypeScript: An object with an index signature
- In real life: Any ticket number can be asked for
- In TypeScript: Any string key can be read
- In real life: The rule: each hook holds one coat
- In TypeScript: The rule: each key holds a
number - In real life: A ticket nobody handed in: an empty hook
- In TypeScript: A key nobody stored:
undefined
An index signature, like [productId: string]: number, describes an object whose keys you don't know ahead of time. It says what type the keys are and what type every value must be. TypeScript checks the values you store, but it can't know which keys are really there.
Writing an index signature
An index signature goes inside an object type, in square brackets: a label for the key, a colon, the key's type, then a colon and the value's type. The key type is usually string. You can read and write keys with a dot too, like stock.tea, as long as the name is a valid JavaScript name.
A type can have named properties as well, but every one of them must fit the index signature:
type Inventory = { updatedAt: string; [productId: string]: number;};Line 2 says an Inventory has an updatedAt that is text. Line 3 says every string key holds a number. But updatedAt is a string key too, so it would have to hold a number. TypeScript says Property 'updatedAt' of type 'string' is not assignable to 'string' index type 'number': the named property breaks the rule.
One fix is to let the values be either type:
type Inventory = { updatedAt: string; [productId: string]: number | string;}; const inventory: Inventory = { updatedAt: "09:00", tea: 3 };const tea = inventory["tea"];// ^?console.log(tea);Line 3 now says number | string: a number or text (Union types covers |). There's no error. But look at the query on line 8: tea is string | number, so every count you read could be text, and you'd have to check before adding. A cleaner fix puts the counts in an object of their own:
type Inventory = { updatedAt: string; counts: { [productId: string]: number };}; const inventory: Inventory = { updatedAt: "09:00", counts: { tea: 3 } };const tea = inventory.counts["tea"];// ^?console.log(tea);Line 3 makes counts a property, with its own index signature. Line 6 fills it in. Line 7 reads inventory.counts["tea"], and the query on line 8 shows a plain number. Line 9 prints 3. Keeping the lookup table apart from the named fields keeps both types simple.
Like any property, an index signature can be readonly, so no key can be changed after the object is made:
const prices: { readonly [productId: string]: number } = { tea: 3, jam: 5 };prices["tea"] = 1;Line 1 makes the prices with a readonly index signature. Line 2 tries to change the tea's price. TypeScript says Index signature in type … only permits reading: you may read any key, but not write one.
The Record type
TypeScript comes with a shorter way to write the same thing: Record<K, V>. You fill in two types inside the angle brackets: K for the keys and V for the values. Record<string, number> means "any string key, holding a number", exactly like the overview's type:
type Stock = Record<string, number>; const stock: Stock = { tea: 3, jam: 5 };stock["mint"] = 8;stock["honey"] = "none";console.log(Object.keys(stock).join(", "));Line 1 makes Stock a Record<string, number>. Lines 3 and 4 work as before. Line 5 gets the same error as the overview, on purpose, because the value must be a number. Line 6 lists the keys with Object.keys (Object.keys, values, entries) and prints tea, jam, mint, honey: the error didn't stop line 5 from running.
Record can do something an index signature with string can't: take a fixed list of keys. A union of strings like "small" | "medium" | "large" means "one of these three words". Give that to Record, and the object must have exactly those keys:
type Size = "small" | "medium" | "large"; const cupPrices: Record<Size, number> = { small: 3, medium: 4,};Line 1 makes Size, the three sizes. Line 3 says cupPrices has a number for every size. Lines 4 and 5 give two. TypeScript marks line 3: Property 'large' is missing in type … but required in type 'Record<Size, number>'. Every key in the list is required. An extra key, like huge: 6, would get the excess property error from the previous lesson.
Reading works the same way. Type a key for line 8 to read, and watch the list under the code:
type Size = "small" | "medium" | "large"; const cupPrices: Record<Size, number> = { small: 3, medium: 4, large: 5,};console.log(cupPrices.medium);mediumNo errors4medium is one of the three keys Size lists. No errors, and line 8 prints 4.
small, medium, and large have no errors and print their prices. huge gets Property 'huge' does not exist, and Medium gets a Did you mean 'medium'? suggestion. With Record<string, number>, every one of those keys would be allowed, and the wrong ones would print undefined.
Record<Size, number> has exactly the three sizes, and needs all of them. Right: Record<string, number> takes any string key.The table below compares the three ways. Read each row as: the type, which keys it allows, and what reading a key it doesn't have does.
| When you write | Which keys it allows | Reading a key it doesn't have |
|---|---|---|
{ [productId: string]: number } | Any string key. | No error. When it runs, you get undefined. |
Record<string, number> | Any string key. It's the same type as the row above, written another way. | No error. When it runs, you get undefined. |
Record<Size, number>, with Size = "small" | "medium" | "large" | Exactly those three keys, and every one must be there. | An error: Property 'huge' does not exist on type 'Record<Size, number>'. |
Keys that aren't there
An index signature says what every key would hold, not which keys exist. So by default, TypeScript says reading any key gives a number, even a key nobody stored. The noUncheckedIndexedAccess setting changes that. It's a compiler option (a setting in your project's tsconfig.json), and strict doesn't turn it on. Press each button:
type Stock = Record<string, number>; const stock: Stock = { tea: 3, jam: 5 };const cakes = stock["cake"];// ^?console.log(cakes + 1);NaNoffnumberNo errorsWith the flag off, TypeScript says cakes is a number, because every key of a Stock holds one. There's no cake, so cakes is really undefined, and line 6 prints NaN, which means "not a number". No error warned you.
Line 3 makes the stock with tea and jam. Line 4 reads cake, which isn't there. The query on line 5 shows the type TypeScript gives it, and line 6 adds 1. With Flag off, the type is number, there are no errors, and it prints NaN. With Flag on, the type is number | undefined, and TypeScript marks line 6 on purpose: 'cakes' is possibly 'undefined' means "this might be nothing, check first". Flag on, with ?? 0 uses 0 when the key is missing (the ?? operator), so it prints 1 with no errors.
cake in stock, so line 4 gets undefined. With the flag off, TypeScript still calls it a number. With noUncheckedIndexedAccess on, it says number | undefined.The flag covers arrays too, because a position like cart[2] may not exist either:
const cart = ["tea", "jam"];const third = cart[2];// ^?console.log(third.toUpperCase());This example turns the flag on; the list under the code shows it. Line 1 makes a cart with two items. Line 2 reads position 2, the third item. The query on line 3 shows string | undefined. Line 4 calls toUpperCase on it, and TypeScript says 'third' is possibly 'undefined'. It's right: when this runs, it throws a TypeError. Keys that a Record lists, like cupPrices.small, are known to exist, so the flag leaves them as plain number.
Maps vs objects
JavaScript has a second way to keep a lookup table: a Map. In TypeScript, you say the key and value types in angle brackets, as with Record:
const tagCounts = new Map<string, number>(); for (const tag of ["sale", "new", "sale"]) { tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);} const sales = tagCounts.get("sale");// ^?console.log(sales, tagCounts.size);Line 1 makes an empty Map<string, number>: string keys, number values. Line 3 loops over three tags. Line 4 adds 1 to each tag's count, using 0 when .get() finds nothing. Line 7 reads the sale count. The query on line 8 shows number | undefined: a Map's .get() always admits it might find nothing, flag or no flag. Line 9 prints 2 2: two sales, and two different tags.
Here is the same counter with a plain object. TypeScript reports nothing, but watch the second tag:
const tagCounts: Record<string, number> = {}; for (const tag of ["sale", "constructor"]) { tagCounts[tag] = (tagCounts[tag] ?? 0) + 1;} console.log(tagCounts["sale"]);console.log(tagCounts["constructor"]);Line 3 loops over sale and constructor. Line 7 prints 1, as expected. Line 8 prints function Object() { [native code] }1. Every plain object already has a few built-in properties, like constructor and toString, that it gets from Object.prototype (the Prototype chain lesson explains where they come from). So tagCounts["constructor"] was never missing: it was a function, and adding 1 joined it into text. The type said number the whole time. When keys come from users, like search tags, a Map avoids this.
The table below compares the two. Read each row as a question, then the answer for a plain object and for a Map.
| Question | A plain object | A Map |
|---|---|---|
| What can a key be? | A string. A number key is turned into a string. | Any value: a string, a number, even an object. |
| What does reading a missing key give? | undefined, typed as the value type unless noUncheckedIndexedAccess is on. | undefined, and .get() always says so: number | undefined. |
Can a built-in name like constructor get in the way? | Yes. Every plain object already has some, from Object.prototype. | No. A Map only holds what you .set(). |
| How do you count the entries? | Object.keys(tagCounts).length. | tagCounts.size. |
Does JSON.stringify save it? | Yes, as a JSON object. | No. A Map turns into {}. |
Translations for a language switcher
A page shows a greeting in the language the visitor picks: English, Hindi, or Tamil. A Record over the list of languages makes sure no language is forgotten:
type Language = "en" | "hi" | "ta"; const greeting: Record<Language, string> = { en: "Hello", hi: "Namaste",}; function greet(language: Language) { return greeting[language];}console.log(greet("ta"));Line 1 lists the three languages. Line 3 says greeting has a string for each. Lines 4 and 5 give English and Hindi, and TypeScript marks line 3: Property 'ta' is missing. Line 8 makes greet, which looks up the chosen language on line 9. Run it anyway, and line 11 prints undefined: a Tamil visitor would see an empty greeting.
type Language = "en" | "hi" | "ta"; const greeting: Record<Language, string> = { en: "Hello", hi: "Namaste", ta: "Vanakkam",}; function greet(language: Language) { return greeting[language];}console.log(greet("ta"));Line 6 adds ta: "Vanakkam". The error is gone, and line 12 prints Vanakkam. If someone adds a fourth language to line 1 later, TypeScript points at every Record that needs a new entry.
const stock: { [id: string]: number } = { tea: 3, jam: "5" };const stock: Record<string, number> = {}; stock.honey = 4;type Inventory = { [id: string]: number; updatedAt: string };type Inventory = { [id: string]: number | string; updatedAt: string };const cups: Record<"small" | "large", number> = { small: 3 };const prices: { readonly [id: string]: number } = { tea: 3 }; prices.tea = 4;const stock = new Map<string, number>(); const tea: number | undefined = stock.get("tea");const stock: Record<string, number> = { tea: 3 }; const cake: number = stock["cake"];
Sort each line by what TypeScript says about it. Each line is checked on its own, with noUncheckedIndexedAccess off.
Common mix-ups
- "An index signature means every key exists." No. It says what a key would hold. A key nobody stored gives
undefinedwhen the code runs. - "
strictturns on noUncheckedIndexedAccess." No. You turn it on by itself intsconfig.json. - "Record is something different from an index signature." No.
Record<string, number>and{ [key: string]: number }are the same type.Recordcan also take a fixed list of keys. - "
Record<Size, number>lets me leave a size out." No. Every key in the list is required. - "After
.has(), a Map's.get()can't be undefined." No. TypeScript still saysnumber | undefined. Read the value once and check it. - "An empty object has no keys." No. It already has built-in ones like
constructorandtoString, which is why a Map is safer for keys that come from users.
Practice exercises
Each exercise is TypeScript. When you fix code, press Edit & run on the starter code: the errors update as you type, and the console shows what it prints. Try a guess first, then use the hints.
What type does TypeScript give cake? Guess first. Then point at cake, or add a // ^? line under it.
const prices: { [name: string]: number } = { tea: 3, jam: 5 };const cake = prices["cake"];const prices: { [name: string]: number } = { tea: 3, jam: 5 };const cake = prices["cake"];// ^?Every string key of prices holds a number, so prices["cake"] is typed number, even though there's no cake. The query on line 3 shows const cake: number.
A stock list with one mistake. Which line will TypeScript mark?
type Stock = Record<string, number>;
const stock: Stock = {
tea: 3,
jam: "5",
mint: 8,
};type Stock = Record<string, number>; const stock: Stock = { tea: 3, jam: 5, mint: 8,};Line 5 gives jam the text "5", and every value of a Record<string, number> must be a number. Writing 5 without quotes fixes it.
Add a large cup that costs 5. What does the fixed code print?
type Size = "small" | "medium" | "large"; const cupPrices: Record<Size, number> = { small: 3, medium: 4,}; console.log(cupPrices.small + cupPrices.medium + cupPrices.large);type Size = "small" | "medium" | "large"; const cupPrices: Record<Size, number> = { small: 3, medium: 4, large: 5,}; console.log(cupPrices.small + cupPrices.medium + cupPrices.large);Record<Size, number> needs all three sizes. With large: 5, the error is gone, and 3 + 4 + 5 prints 12. The starter printed NaN, because large was undefined.
Fix the Inventory type so both errors go away, without making the counts number | string. What does your code print?
type Inventory = { updatedAt: string; [productId: string]: number;}; const inventory: Inventory = { updatedAt: "09:00", tea: 3 };console.log(inventory["tea"]);type Inventory = { updatedAt: string; counts: { [productId: string]: number };}; const inventory: Inventory = { updatedAt: "09:00", counts: { tea: 3 } };console.log(inventory.counts["tea"]);With the counts in their own object, updatedAt no longer has to be a number, and inventory.counts["tea"] is a plain number. It prints 3.
restock adds 10 to a product's count. TypeScript marks line 4. Fix it so a new product starts from 0. What does your code print?
const stock: Record<string, number> = { tea: 3, jam: 5 }; function restock(productId: string) { stock[productId] = stock[productId] + 10; return stock[productId];} console.log(restock("cake"));const stock: Record<string, number> = { tea: 3, jam: 5 }; function restock(productId: string) { stock[productId] = (stock[productId] ?? 0) + 10; return stock[productId];} console.log(restock("cake"));There's no cake, so stock["cake"] is undefined, and the starter prints NaN. With ?? 0, a missing product starts from 0, so restocking cake gives 10.
The counter prints NaN, and TypeScript marks line 4. Fix it. What does your code print?
const counts = new Map<string, number>(); for (const tag of ["sale", "new", "sale"]) { counts.set(tag, counts.get(tag) + 1);} console.log(counts.get("sale"));const counts = new Map<string, number>(); for (const tag of ["sale", "new", "sale"]) { counts.set(tag, (counts.get(tag) ?? 0) + 1);} console.log(counts.get("sale"));.get() gives number | undefined. The first time, it's undefined, and undefined + 1 is NaN, so the starter prints NaN. Starting from 0 counts sale twice: it prints 2.
Check your understanding
For each question, keep two things apart: which keys the type allows, and which keys the object really has when the code runs. The quiz shows its code without the checker, so the error list doesn't give the answers away.
Question 1 of 7What does the type
{ [productId: string]: number }describe?Choose an answer to see the explanation.
Question 2 of 7What does this price lookup print, and does TypeScript complain?
Read the code, then predictconst prices: Record<string, number> = { tea: 3 }; const cake = prices["cake"]; console.log(cake + 1);Choose an answer to see the explanation.
Question 3 of 7Why does TypeScript flag line 3 of these cup prices?
Read the code, then predicttype Size = "small" | "large"; const cupPrices: Record<Size, number> = { small: 3 };Choose an answer to see the explanation.
Question 4 of 7With noUncheckedIndexedAccess on, what type does
teaget on line 2?Read the code, then predictconst stock: Record<string, number> = { tea: 3 }; const tea = stock["tea"];Choose an answer to see the explanation.
Question 5 of 7Does TypeScript accept line 3 of this Map code?
Read the code, then predictconst stock = new Map<string, number>([["tea", 3]]); if (stock.has("tea")) { const tea: number = stock.get("tea"); console.log(tea); }Choose an answer to see the explanation.
Question 6 of 7What does this empty counts object print?
Read the code, then predictconst counts: Record<string, number> = {}; console.log(typeof counts["toString"]);Choose an answer to see the explanation.
Question 7 of 7When is a Map a better fit than a plain object?
Choose an answer to see the explanation.
Key takeaways
- An index signature,
[productId: string]: number, types an object whose keys you don't know ahead of time. - Every named property in the same type must fit the index signature. Keep a lookup table in a property of its own.
Record<string, number>is the same type.Record<Size, number>takes a list of keys and needs every one.- By default, reading any key is typed as the value type.
noUncheckedIndexedAccessaddsundefined, so you check first. - A
Map's.get()always givesvalue | undefined, and a Map has no built-in keys. Use one for keys that come from users.
Remember the one-liner.
An index signature says what any key would hold, not which keys are there.
Coming next: Intersection types. It combines object types with &, so one object has every property from both.