The dark side of Record in TypeScript
Tags: typescript
Published: 2022-07-03 16:58:09

`Record` is a [global utility type](https://www.typescriptlang.org/docs/handbook/utility-types.html#recordkeys-type) provided by TypeScript that constructs an object type whose keys are `TKey` and values are `TValue`.

```ts
Record<TKey, TValue>
```

`Record` is handy when you need to map the properties of a type to another type. For instance, below we map `ColorName` to the object of type `Color`. 

```ts
type ColorName = 'blue' | 'yellow' | 'white';

interface Color {
  hex: string;
  rgb: { r: number; g: number; b: number };
}

const colors: Record<ColorName, Color> = {
  blue: { hex: '#0057b7', rgb: { r: 0, g: 87, b: 183 } },
  yellow: { hex: '#ffd700', rgb: { r: 255, g: 215, b: 0 } },
  white: { hex: '#ffffff', rgb: { r: 255, g: 255, b: 255 } }
};
```

---

Before unveiling the dark side of `Record` let's first understand so-called exhaustive types.

## Exhaustive vs. Non-Exhaustive Types

A type is exhaustive if it has a finite number of possible values. For instance, a union type of string literals or an enum are exhaustive types:

```ts
type ColorName = 'blue' | 'yellow' | 'white';

enum Priority {
  low = 'low',
  normal = 'normal',
  high = 'high',
}
```

On the contrary, a type is non-exhaustive if it has an infinite number of possible values. For example, `string` or `number`.

---

Often times developers mistakenly think of `Record` as a normal JS object. In other words, they use it with both exhaustive and non-exhaustive keys. This eventually leads to runtime errors like "Cannot read properties of undefined".

For instance, let's use `string` (non-exhaustive) instead of `ColorName` (exhaustive) as a key type for our `Record` object to see what it changes. 

```ts
// We tell TypeScript that values of `colors` will be of type `Color`.
const colors: Record<string, Color> = {};
// But in practice they can be `undefined` too.
const blue = colors["blue"];
// There is no TypeScript error below but you'll get a runtime error if `colors` doesn't contain a value for the key "blue".
const hex = blue.hex;
//              ^ Uncaught TypeError: Cannot read properties of undefined
```

It's very easy to make such a mistake and, based on my experience, it's a pretty common one. Especially if you or your teammates are new to TypeScript. 

I believe that using `Record` with non-exhaustive keys should be frowned upon and discouraged.

---

Let's look at some type-safe ways to create objects with a non-exhaustive key. 

## `Map`

[Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) is a native JS data structure that is supported across all major browsers. It can be used to create a dictionary with non-exhaustive keys as its `get` method will always return an optional value. 

```ts
const colors: Map<string, Color> = new Map<string, Color>();
const blue = colors.get('blue');
// We get the following TypeScript error if we try to access a value from the `colors` map. 
const hex = blue.hex;
//          ^^^^ Object is possibly 'undefined'.(2532)
```

## `PartialRecord`

Another alternative is a custom type that will enforce optionality for object values. Let's call it `PartialRecord`. 

```ts
type PartialRecord<TKey extends PropertyKey, TValue> = {
  [key in TKey]?: TValue;
}
```

It can be used interchangeably with the `Record` type when you work with non-exhaustive keys. 

```ts
const colors: PartialRecord<string, Color> = {};
const blue = colors['blue'];
// Similar to `Map`, we also get a TypeScript error if we try to access a value from the `colors` object. 
const hex = blue.hex;
//          ^^^^ Object is possibly 'undefined'.(2532)
```

## Summary

`Record` works great for situations when you want to map an exhaustive type to another type. If we didn't explicitly enumerate all values of `ColorName`, TypeScript would immediately tell us what we need to fix:

```ts
type ColorName = 'blue' | 'yellow' | 'white';

const colors: Record<ColorName, Color> = {
//    ^^^^^^ Property 'blue' is missing in type ...
  yellow: { hex: '#ffd700', rgb: { r: 255, g: 215, b: 0 } },
  white: { hex: '#ffffff', rgb: { r: 255, g: 255, b: 255 } }
};
```

However, when you work with non-exhaustive keys you'll be better off with `Map` or a custom type like `PartialRecord`. I personally prefer `PartialRecord` because of its neat initialisation and resemblance of `Record`:

```ts
// Initialisation using `Map`.
const colorsMap: Map<string, Color> = new Map<string, Color>([
  ["yellow", { hex: "#ffd700", rgb: { r: 255, g: 215, b: 0 } }],
]);

// Initialisation using `PartialRecord`.
const colorsObject: PartialRecord<string, Color> = {
  yellow: { hex: "#ffd700", rgb: { r: 255, g: 215, b: 0 }
}
```

Let me know in the comments if you've experienced similar issues with `Record` and what approach you took to prevent them in the future.