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>
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 } }
};
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',
}
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
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)
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;
}
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)
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 } }
};
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 }
}
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.
Top comments (2)
What do you think about tsconfig 'noUncheckedIndexedAccess' in the case of Record?
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!