Be Careful With TypeScript Records

Suppose we have the following TypeScript code that defines an object map for grocery items using the Record utility:

interface Item {
  id: string;
  category: string;
}

type Items = Record<string, Item>;

const items: Items = {
  apple: {
    id: 'apple',
    category: 'produce',
  },
  bread: {
    id: 'bread',
    category: 'bakery',
  },
};

As you might expect, when we access apple (or bread) from items, TypeScript knows we are talking about the Item type:

const apple = items.apple; // apple is of type Item
console.log(apple.category); // produce

Now, suppose we try to access an item that isn't in the object map, e.g. milk. How does TypeScript handle it? It also types it as an Item:

const milk = items.milk; // ❗ milk is of type Item
console.log(milk.category); // oops

TypeScript has no issues with this code; Record<string, Item> literally means every string key on items is an Item

Given this usage of the object map, Items should be typed like this:

type Items = Record<string, Item | undefined>;

Now, TypeScript will enforce that we check for a truthy value from items before moving forward:

const milk = items.milk; // milk can be undefined

if (milk) {
  console.log(milk.category);
}

noUncheckedIndexedAccess

If you enable noUncheckedIndexedAccess in your TSConfig, TypeScript will add undefined to any un-declared field in the type. This means that adding undefined to the Items record from earlier would be unnecessary.