DEV Community

Cover image for The Record Utility Type in TypeScript
Chris Bongers
Chris Bongers

Posted on • Originally published at daily-dev-tips.com

The Record Utility Type in TypeScript

I won't lie. There is a reason I skipped this one for a bit, it was a bit unclear on when to use this one, but it's starting to make sense.

The record utility type constructs an object type having keys and some other type.

This means you can narrow down your records by only excepting specific keys or types of keys.

Let's dive into those different scenario's

The TypeScript Record type

Let's say we have a single user interface, as we have seen before like this:

interface User {
  id: number;
  firstname: string;
  lastname: string;
  age?: number;
}
Enter fullscreen mode Exit fullscreen mode

Now, what happens if we want to make an array of all users?

This is exactly a cool use-case for the record type, and let's say we want to map them by a number, it could look something like this:

const users: Record<number, User> = {
  0: {id: 1, firstname: 'Chris', lastname: 'Bongers'},
  1: {id: 2, firstname: 'Yaatree', lastname: 'Bongers', age: 2},
};
Enter fullscreen mode Exit fullscreen mode

As you can see, this will create a map of users identified by a number.

The main Syntax for the record type looks like this:

Record<Keys, Type>
Enter fullscreen mode Exit fullscreen mode

So we can also say in the above example we want the identifier to be a string.

const users: Record<string, User> = {
  123: {id: 1, firstname: 'Chris', lastname: 'Bongers'},
  456: {id: 2, firstname: 'Yaatree', lastname: 'Bongers', age: 2},
};
Enter fullscreen mode Exit fullscreen mode

Making sure keys match

Since the first option accepts keys, we can use one more little trick: to pass a union type to the record.

By doing this, we ensure that only valid keys can be passed.

Let's say we have a type of admin user (a weird example, but let's go with it).

type Admins = 'chris' | 'nicole';
Enter fullscreen mode Exit fullscreen mode

And we want to make sure we can only assign these keys to our list of admin users.

const adminUsers: Record<Admins, User> = {
  chris: {id: 1, firstname: 'Chris', lastname: 'Bongers'},
  nicole: {id: 2, firstname: 'Nicole', lastname: 'Bongers'},
};
Enter fullscreen mode Exit fullscreen mode

If we now try to pass anything else, we'll be hit by an error.

const adminUsers: Record<Admins, User> = {
  chris: {id: 1, firstname: 'Chris', lastname: 'Bongers'},
  nicole: {id: 2, firstname: 'Nicole', lastname: 'Bongers'},
  yaatree: {id: 3, firstname: 'Yaatree', lastname: 'Bongers'},
};
Enter fullscreen mode Exit fullscreen mode

This will throw the following error, stating Yaatree is not a valid key.

TypeScript record type error

Some other examples

In the union type article, we saw a Status type, which was used to identify unique status objects.

type Status = 'not_started' | 'progress' | 'completed' | 'failed';
Enter fullscreen mode Exit fullscreen mode

Now we want to assign certain variables to this type, a color, and an icon.

This is another perfect example where a record can make sure only to accept the types we defined.

const statusTypes: Record<Status, {icon: string, color: string}> = {
  not_started: {icon: 'icon-not-started', color: 'gray'},
  progress: {icon: 'icon-progress', color: 'orange'},
  completed: {icon: 'icon-completed', color: 'green'},
  failed: {icon: 'icon-failed', color: 'red'},
};
Enter fullscreen mode Exit fullscreen mode

And that's it. A super powerful and strict utility type called the Record type.

Thank you for reading, and let's connect!

Thank you for reading my blog. Feel free to subscribe to my email newsletter and connect on Facebook or Twitter

Top comments (8)

Collapse
 
peerreynders profile image
peerreynders • Edited
type User = {
  id: number;
  firstname: string;
  lastname: string;
  age?: number;
};

// ES2019
const users: Record<number, User> = Object.fromEntries(
  [
    { id: 1, firstname: 'Chris', lastname: 'Bongers' },
    { id: 2, firstname: 'Yaatree', lastname: 'Bongers', age: 2 },
  ].map((user) => [user.id, user])
);

for (const key of Object.getOwnPropertyNames(users))
  console.log(`Key: ${key} of type ${typeof key}`);

// output:
// "Key: 1 of type string"
// "Key: 2 of type string"
Enter fullscreen mode Exit fullscreen mode

Nice going, TypeScript! ๐Ÿคฆ

MDN: Objects and properties:

Please note that all keys in the square bracket notation are converted to string unless they're Symbols, since JavaScript object property names (keys) can only be strings or Symbols

type User = {
  id: number;
  firstname: string;
  lastname: string;
  age?: number;
};

// correctly typed as
//
// const users: {
//   [k: string]: User;
// }
const users = Object.fromEntries<User>(
  [
    { id: 1, firstname: 'Chris', lastname: 'Bongers' },
    { id: 2, firstname: 'Yaatree', lastname: 'Bongers', age: 2 },
  ].map((user) => [user.id, user])
);

for (const key of Object.getOwnPropertyNames(users))
  console.log(`Key: ${key} of type ${typeof key}`);

// output:
// "Key: 1 of type string"
// "Key: 2 of type string"
Enter fullscreen mode Exit fullscreen mode
type User = {
  id: number;
  firstname: string;
  lastname: string;
  age?: number;
};

type UsersById = Map<number, User>;

const users: UsersById = new Map(
  [
    { id: 1, firstname: 'Chris', lastname: 'Bongers' },
    { id: 2, firstname: 'Yaatree', lastname: 'Bongers', age: 2 },
  ].map((user) => [user.id, user])
);

for (const key of users.keys())
  console.log(`Key: ${key} of type ${typeof key}`);

// output:
// "Key: 1 of type number"
// "Key: 2 of type number"
Enter fullscreen mode Exit fullscreen mode
Collapse
 
dailydevtips1 profile image
Chris Bongers

Ah thanks for adding this Peer,
valid point indeed ๐Ÿ™Œ

Collapse
 
peerreynders profile image
peerreynders • Edited

This is just one of those examples to demonstrate that just because you're using TypeScript doesn't mean you can check your brain at the door. If you think TypeScript has your back, think again; you still need to have a good grasp of how JavaScript works.

To be fair TypeScript to some degree is just being consistent in it's own way but sometimes (ironically) without taking JavaScript's idiosyncrasies into account.

Take the following variation:

type User = {
  id: number,
  firstname: string,
  lastname: string,
  age?: number,
};

const fee = Symbol('fee');
const fi = Symbol('fi');

// Why is there no TS error here?
const users: Record<number, User> = Object.fromEntries([
  [fee, { id: 1, firstname: 'Chris', lastname: 'Bongers' }],
  [fi, { id: 2, firstname: 'Yaatree', lastname: 'Bongers', age: 2 }],
]);

for (const key of Object.getOwnPropertySymbols(users))
  console.log(`Key: ${String(key)} of type ${typeof key}`);

console.log(users[fee]); // Access works but TS Error:
console.log(users[fi]); // "Element implicitly has an 'any' type because index expression is not of type 'number'."

// output:
// "Key: Symbol(fee) of type symbol""
// "Key: 2 of type string"
//
// {
//   "id": 1,
//  "firstname": "Chris",
//  "lastname": "Bongers"
// }
// {
//  "id": 2,
//  "firstname": "Yaatree",
//  "lastname": "Bongers",
//  "age": 2
// }
Enter fullscreen mode Exit fullscreen mode

For some reason TypeScript doesn't catch the problem around users: Record<number>.

The initial problem happens around ObjectFromEntries<T>. The definition

fromEntries<T = any>(entries: Iterable<readonly [PropertyKey, T]>): { [k: string]: T };
Enter fullscreen mode Exit fullscreen mode

That definition covers the majority case but we actually have { [k: symbol]: User } here.

Then for some reason there is no complaint that we are binding a supposed { [k: string]: User } to a Record<number, User>:

es5.d.ts

type Record<K extends keyof any, T> = {
    [P in K]: T;
};
Enter fullscreen mode Exit fullscreen mode

i.e. it has no problem accepting a { [k: string]: User } for a { [k: number]: User }?

It's only once we get down to users[fee] that the complaining starts: "This type's properties should only be accessed with a number".

So my takeaway:

  • Use Records with string, symbol or a union of string literal types for Keys.
  • While any type that can be coerced to string, like number, will work, TypeScript will enforce the usage of the declared Keys type - forcing type coercion for any access at runtime. So the static typing "works" but really doesn't jive with what is happening at runtime. My personal preference is to enforce the actual types that are present at runtime.
  • If a Keys type is needed that isn't a string or symbol use a Map instead. However unlike Record<Keys,Type> Map won't require all the members of a Keys union.

Partial<Type> can be a companion utility to a Record<Key,Type> that uses a union for Keys:

const KEYS = ['id', 'firstname', 'lastname'] as const;
type Keys = typeof KEYS[number];

type UserDataEntries = [Keys, string][];
type User = Record<Keys, string>;

const userData: [string, string][] = [
  ['id', '1'],
  ['firstname', 'Chris'],
  ['lastname', 'Bongers'],
];

if (!isUserDataEntries(userData)) throw new Error('Invalid user data entries');
// Is now
// const userData: UserDataEntries

// const user: Partial<User>
const user = userData.reduce<Partial<User>>((temp, [key, value]) => {
  temp[key] = value;
  return temp;
}, {});

if (!isUser(user)) throw new Error('Incomplete user data');
// Is now
// const user: User
console.log('Success:', user);

// type predicates
// https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates
//
function isUserDataEntries(data: [string, string][]): data is UserDataEntries {
  const validKeys: readonly string[] = KEYS; // deliberately widen type
  return data.every(([key, _value]) => validKeys.includes(key));
}

function isUser(partial: Partial<User>): partial is User {
  // ES2022
  return KEYS.every((key) => Object.hasOwn(partial, key));
}
Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
dailydevtips1 profile image
Chris Bongers

100% agree with you, just because we are Typescript we can't just leave things to it.
We should still validate everything.

Just one small question about your initial comment,
just because it handles the numbers as strings, is that really a concern though?

In most cases it's not really used anyway (just wondering why we should even care about that)

Thread Thread
 
peerreynders profile image
peerreynders • Edited

just because it handles the numbers as strings, is that really a concern though?

On the surface no, because TypeScript tries to enforce consistent access with the declared type.

But it still leaves a JavaScript type coercion footgun in place:

const record: Record<string | number, string> = {
  0: 'zero',
  1: 'one',
  2: 'two',
};

const oldOne = 1;
const newOne = '1';

console.assert(record[oldOne] === 'one');
record[newOne] = 'newOne';
// Oops
console.assert(record[oldOne] === 'newOne');

// Meanwhile TS flags this as an error
// "This condition will always return 'false' since the types 'number' and 'string' have no overlap"
// Actually it returns `true` due to type coercion; TS just doesn't like it...
// console.assert(oldOne == newOne);

// ...because
console.assert(typeof oldOne !== typeof newOne);

// At least this works (ES2022)
console.assert(Object.hasOwn(record, 2));
console.assert(Object.hasOwn(record, (2).toString()));
Enter fullscreen mode Exit fullscreen mode

Similarly

const values = ['zero', 'one', 'two'];

const oldOne = 1;
const newOne = '1';
const anotherOne = '01';

console.assert(values[oldOne] === 'one');

values[newOne] = 'newOne';
console.assert(values[oldOne] === 'newOne');

console.assert(typeof oldOne !== typeof newOne);

console.assert(Object.hasOwn(values, oldOne));
console.assert(Object.hasOwn(values, oldOne.toString()));
console.assert(Object.hasOwn(values, newOne));
console.assert(!Object.hasOwn(values, anotherOne));
Enter fullscreen mode Exit fullscreen mode

So TypeScript being typed had an opportunity to clean things up at least for objects to restrict property keys to strings and symbols but existing JavaScript code bases sometimes use numbers as property keys (for convenience) and not supporting that could have hurt adoption.


Collapse
 
itsjzt profile image
Saurabh Sharma • Edited

I use Record and Pick/Omit all the time. ๐Ÿ’—

Collapse
 
dailydevtips1 profile image
Chris Bongers

Awesome very cool combi

Collapse
 
dailydevtips1 profile image
Chris Bongers

Awesome, happy you enjoyed it Anjan