DEV Community

Cover image for The dark side of Record in TypeScript

The dark side of Record in TypeScript

Record is a global utility type provided by TypeScript that constructs an object type whose keys are TKey and values are TValue.

Record<TKey, TValue>
Enter fullscreen mode Exit fullscreen mode

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.

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 } }
};
Enter fullscreen mode Exit fullscreen mode

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:

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

enum Priority {
  low = 'low',
  normal = 'normal',
  high = 'high',
}
Enter fullscreen mode Exit fullscreen mode

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.

// 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
Enter fullscreen mode Exit fullscreen mode

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

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)
Enter fullscreen mode Exit fullscreen mode

PartialRecord

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

type PartialRecord<TKey extends PropertyKey, TValue> = {
  [key in TKey]?: TValue;
}
Enter fullscreen mode Exit fullscreen mode

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

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)
Enter fullscreen mode Exit fullscreen mode

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:

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 } }
};
Enter fullscreen mode Exit fullscreen mode

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:

// 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 }
}
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (2)

Collapse
lokhmakov profile image
Pavel Lokhmakov

What do you think about tsconfig 'noUncheckedIndexedAccess' in the case of Record?

Collapse
zuta profile image
Taras Zubyk πŸ‡ΊπŸ‡¦ Author

Hi Pavel!

You bring up a good point. I wasn't aware of this fairly new flag in tsconfig.

noUncheckedIndexedAccess certainly seems like a possible solution to the problem explained in the post. It is however disabled by default and only available starting from TS v4.1.

I'll update the post to include the noUncheckedIndexedAccess bit.

Thank you!