cf.completefrontend
Open lab My progress
THE TYPESCRIPT FIELD GUIDE

Index signatures & Record

Type lookup tables and dictionaries whose keys you don't know ahead of time, with index signatures, Record, noUncheckedIndexedAccess, and Map.

By the end, you can
  • 01
    Type a lookup tableWrite { [key: string]: number } or Record<string, number> for an object whose keys come from data.
  • 02
    Choose the keysUse Record with a list of keys when every key must be there, and read the errors when one is missing or unknown.
  • 03
    Read missing keys safelyTurn on noUncheckedIndexedAccess, handle undefined, and pick a Map when 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:

Stock counts keyed by productPop out in the code editor (opens in a new tab)JavaScript
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":

A type for any product ID (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
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 shows const count: number, and line 9 prints 5.
Left, the index signature [productId: string]: number, one rule for every key. An arrow leads to a window for stock, with three cards: tea 3, jam 5, and mint 8. Every key holds a number.the rule in the type[productId: string]: numberone rule for every keystocktea: 3jam: 5mint: 8every key holds a number
The type doesn't name tea, jam, or mint. It gives one rule: any string key, holding a number. Line 4 adds mint, and the rule covers it too.
Real-life analogyA cloakroom with numbered hooks

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
The main idea

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:

A named property that doesn't fit (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
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:

Values that can be a number or textPop out in the code editor (opens in a new tab)TypeScript
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:

The counts in an object of their ownPop out in the code editor (opens in a new tab)TypeScript
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:

A price list you can only read (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
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:

The same stock, written with Record (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
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:

Cup prices with a size left out (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
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:

Playground: read a cup size
Cup prices for three sizesPop out in the code editor (opens in a new tab)TypeScript
type Size = "small" | "medium" | "large"; const cupPrices: Record<Size, number> = {  small: 3,  medium: 4,  large: 5,};console.log(cupPrices.medium);
Line 8 readsmedium
What happens
TypeScriptNo errors
Line 8 prints4
Try it yourself

medium is one of the three keys Size lists. No errors, and line 8 prints 4.

The code is checked by the real TypeScript compiler each time you type. Only the key on line 8 changes. Reset puts medium back.

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.

Left, cupPrices, a Record<Size, number>, holds exactly three cards: small 3, medium 4, and large 5. Right, stock, a Record<string, number>, holds tea 3 and jam 5, and a card that says any other key.Record<Size, number>cupPricessmall: 3medium: 4large: 5exactly these three keysRecord<string, number>stocktea: 3jam: 5… any other keyany string key
Left: 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.

Index signatures and Record, side by side
When you writeWhich keys it allowsReading 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:

Playground: read a key that isn't there
Reading a cake countPop out in the code editor (opens in a new tab)TypeScript
type Stock = Record<string, number>; const stock: Stock = { tea: 3, jam: 5 };const cakes = stock["cake"];//    ^?console.log(cakes + 1);
Console outputNaN
What happens
The flagoff
Type of cakesnumber
TypeScriptNo errors
Try it yourself

With 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.

TypeScript checks the code for real, with the flag set as shown under the code, and the output is what the code prints. Reset goes back to Flag off.

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.

A card reading stock["cake"] points at a window for stock, which holds tea 3 and jam 5 and no cake. An arrow out of the window leads to a card that says undefined. Below, with the flag off TypeScript says number; with the flag on it says number | undefined.the read on line 4stock["cake"]stocktea: 3jam: 5no cake hereundefinedflag off, TypeScript says: numberflag on: number | undefined
There's no 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:

An array position that may be empty (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
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:

Counting search tags with a MapPop out in the code editor (opens in a new tab)TypeScript
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:

The same counter with a plain objectPop out in the code editor (opens in a new tab)TypeScript
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.

A plain object or a Map for a lookup table
QuestionA plain objectA 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:

Greetings with one language missing (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
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.

Greetings for every languagePop out in the code editor (opens in a new tab)TypeScript
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.

Error or fine?
  • 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"];
Try it yourself
0 of 8 correct

Sort each line by what TypeScript says about it. Each line is checked on its own, with noUncheckedIndexedAccess off.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "An index signature means every key exists." No. It says what a key would hold. A key nobody stored gives undefined when the code runs.
  • "strict turns on noUncheckedIndexedAccess." No. You turn it on by itself in tsconfig.json.
  • "Record is something different from an index signature." No. Record<string, number> and { [key: string]: number } are the same type. Record can 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 says number | undefined. Read the value once and check it.
  • "An empty object has no keys." No. It already has built-in ones like constructor and toString, 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.

Exercise 1 · Warm-upRead a lookup's type

What type does TypeScript give cake? Guess first. Then point at cake, or add a // ^? line under it.

Starter codePop out in the code editor (opens in a new tab)TypeScript
const prices: { [name: string]: number } = { tea: 3, jam: 5 };const cake = prices["cake"];

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upFind the value that doesn't fit

    A stock list with one mistake. Which line will TypeScript mark?

    Starter codePop out in the code editor (opens in a new tab)TypeScript
    type Stock = Record<string, number>;
    
    const stock: Stock = {
      tea: 3,
      jam: "5",
      mint: 8,
    };

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeAdd the missing size

      Add a large cup that costs 5. What does the fixed code print?

      Starter codePop out in the code editor (opens in a new tab)TypeScript
      type Size = "small" | "medium" | "large"; const cupPrices: Record<Size, number> = {  small: 3,  medium: 4,}; console.log(cupPrices.small + cupPrices.medium + cupPrices.large);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeKeep the counts apart

        Fix the Inventory type so both errors go away, without making the counts number | string. What does your code print?

        Starter codePop out in the code editor (opens in a new tab)TypeScript
        type Inventory = {  updatedAt: string;  [productId: string]: number;}; const inventory: Inventory = { updatedAt: "09:00", tea: 3 };console.log(inventory["tea"]);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeRestock a product that isn't there

          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?

          Starter codePop out in the code editor (opens in a new tab)TypeScript
          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"));

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeCount tags with a Map

            The counter prints NaN, and TypeScript marks line 4. Fix it. What does your code print?

            Starter codePop out in the code editor (opens in a new tab)TypeScript
            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"));

            Answer, then press Check. Spacing and letter case don’t matter.

              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.

              The index signatures & Record quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does the type { [productId: string]: number } describe?

                Choose an answer to see the explanation.

              2. Question 2 of 7What does this price lookup print, and does TypeScript complain?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                const prices: Record<string, number> = { tea: 3 };
                const cake = prices["cake"];
                console.log(cake + 1);

                Choose an answer to see the explanation.

              3. Question 3 of 7Why does TypeScript flag line 3 of these cup prices?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                type Size = "small" | "large";
                
                const cupPrices: Record<Size, number> = { small: 3 };

                Choose an answer to see the explanation.

              4. Question 4 of 7With noUncheckedIndexedAccess on, what type does tea get on line 2?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                const stock: Record<string, number> = { tea: 3 };
                const tea = stock["tea"];

                Choose an answer to see the explanation.

              5. Question 5 of 7Does TypeScript accept line 3 of this Map code?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                const 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.

              6. Question 6 of 7What does this empty counts object print?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                const counts: Record<string, number> = {};
                console.log(typeof counts["toString"]);

                Choose an answer to see the explanation.

              7. 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. noUncheckedIndexedAccess adds undefined, so you check first.
              • A Map's .get() always gives value | 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.

              CompleteFrontend Clear concepts. Working examples.