A Guide to The TypeScript Record Type

A Guide to The TypeScript Record Type
Record type in action, overlaid on top of Mike Yukhtenko’s "Desert at Night" from Unsplash

It sometimes feels like TypeScript has multiple ways to do the same thing. So, I don’t blame you if you’re not sure about all the pros and cons of using TypeScript’s Record type. It’s a powerful utility that simplifies the creation of object types with specific keys and values, but it has some nuances and potential pitfalls that dev teams can run into.

What’s a Record Anyway?

At its core, the TypeScript Record type is a utility that enables developers to construct an Object type with a predefined set of keys and a uniform type for their values. The basic syntax of Record is Record<K, T>, where K represents the type of the keys, and T denotes the type of the values. This is what you’d call a generic type. Since this is going to compile to a JavaScript Object at the end of the day, the key will have to be some sort of string, number, or Symbol.

In this minimal example, we setup a type for an object that will work as a mapping for a simple multiplayer game. It maps the username of a player to their total points. So our key is a string, and our value a number.

type UserIdToPoints = Record<string, number>;

const userPoints: UserIdToPoints = {
  'alice@': 100,
  'bob@': 50,
};

This is the typical example you’ll fine on the web, yet this code has a major pitfall. You may have noticed what the problem is already. (If not, think about the "contractual promise" our chosen key type is making.) We’ll get back to this in a bit I promise!

A More Advanced TypeScript Record Use-case

Let’s look at a more "real world" example for the Record type. Imagine you’re building a job board app that let’s a company create job postings. There’s certain actions that users can perform, based on their role (team owner, leader, or member) and we want to have a an easy way to represent this in our system.

This is a great use-case for Record! We can represent the roles as the Key and the have a list of actions as the type of the Value. Here we’re going to use Union types on a bunch of strings to represent the valid actions and roles our app supports.

type ActionType = 'manage-team' | 'create-job' | 'edit-job' | 'delete-job' | 'view-job';
type ApplicationRole = 'owner' | 'leader' | 'member';

type PermissionsRecord = Record<ApplicationRole, ActionType[]>;

const userActions: PermissionsRecord = {
  owner: ['manage-team', 'create-job', 'edit-job', 'delete-job', 'view-job'],
  leader:  ['create-job', 'edit-job', 'delete-job', 'view-job'],
  member: ['create-job', 'edit-job', 'view-job'],
};

Now, let’s look at how the type safety kicks in. For starters, the Record will expect total coverage of our chosen key type. If I forget to add member, we get a type error.

const userActions: PermissionsRecord = {
  owner: ['manage-team', 'create-job', 'edit-job', 'delete-job', 'view-job'],
  leader:  ['create-job', 'edit-job', 'delete-job', 'view-job'],
};

Error: Property 'member' is missing in type '{ owner: ("manage-team" | "create-job" | "edit-job" | "delete-job" | "view-job")[]; leader: ("create-job" | "edit-job" | "delete-job" | "view-job")[]; }' but required in type 'PermissionsRecord'.(2741)

And if I add something outside of the expected type of our value, then we also get a type error:

const userActions: PermissionsRecord = {
  owner: ['manage-team', 'create-job', 'edit-job', 'delete-job', 'view-job'],
  leader:  ['create-job', 'edit-job', 'delete-job', 'view-job'],
  member: ['create-job', 'edit-job', 'view-job', 'unknownAction'],
};

Error: Type '"unknownAction"' is not assignable to type 'ActionType'.(2322)

The "Key" Pitfall of the Record Type

The two examples so far differ in one major way – the choice of the Key type. And, if you’ll pardon the pun, the "Key" is one of the key pitfalls I see developers struggle with.

In our second example, the key is a very exhaustive type and it contributed a lot to the maintainability and stability of our code. If another developer adds one more role to the ApplicationRole type, the TypeScript compiler will not compile the code until the userActions Record declaration is updated. This prevents a drift between the data model and the permission mapping and helps you catch bugs quickly. It’s also extremely useful if you need to refactor and rename things across the codebase.

But our first example had a broader scope in the Record’s Key type – since we chose a generic string – so there’s no exhaustive checking that could happen. And, because of this, TypeScript will let us get away with giving a false promise: each member of this object returns a value.

But this is simply, not true. For Example:

type UserIdToPoints = Record<string, number>;

const userPoints: UserIdToPoints = {
  'alice@': 100,
  'bob@': 50,
};


const getPointsForUser = (record: UserIdToPoints, username: string): number => {
  return record[username];
}

console.log(getPointsForUser(userPoints, 'jon@'));
// ^-- Returns undefined, but we were promised a number!

So, what do we do here? How do we make sure we’re actually fulfilling our contract’s promise and, ideally, write code that catches errors at compile time instead of runtime?

There’s a lot of things the author of the getPointsForUser method can do here. For example, they could modify their function’s signature, return -1 as a default, or even throw an explicit error. Here’s all three:

const getPointsForUser1 = (record: UserIdToPoints, username: string): number | undefined => {
  return record[username];
}


// Returns -1 if the user does not exist
const getPointsForUser2 = (record: UserIdToPoints, username: string): number => {
  return record[username] ?? -1;
}

// Throws an error if the user does not exist
const getPointsForUser3 = (record: UserIdToPoints, username: string): number | never => {
  const val = record[username];
  if (val === undefined) {
    throw Error('User does not exist');
  }
  return val;
}

These are all reasonable solutions, but we are not out of the woods, yet. We’re still stuck with a big problem: what if a developer doesn’t know the pitfalls of UserIdToPoints? Imagine that they use a function in their code getPointsMap that returns this type. Now there’s two problems that can proliferate:

  • They might write code that doesn’t handle the undefined case and they will produce a bug that will lead to a runtime exception.
  • They might realize this problem, but solve it with a different approach than what’s been used in the different parts of the code. (For example, developer A decided to return -1, but developer B decided to throw exceptions.)

A well-functioning team will have various mechanisms to stop this such as code reviews and pre-agreed best practices that everyone follows. But, it would sure be great if we could guard against this from the start.

TypeScript Record with Optional Keys

The way we could avoid confusion when dealing with any potential key is to just append an undefined type as a potential value. Going back to our example from earlier:

type UserIdToPoints = Record<string, number | undefined>;

const userPoints: UserIdToPoints = {
  'alice@': 100,
  'bob@': 50,
};


const getPointsForUser1 = (record: UserIdToPoints, username: string): number | undefined => {
  return record[username];
}

console.log(getPointsForUser1(userPoints, 'jon@'));
// ^-- returns undefined, but now this is a normal expectation and the caller needs to handle it

This really simplifies thing and helps us highlight that our Record in this case is pretty brittle. It’s actually interesting to compare this against our permissions example which was very safe!

But as you write a lot of TypeScript code you’ll soon start to feel that the above is kind of tedious from a DX angle. You’ll also find yourself discovering use cases where it’s important to know the difference between a value being undefined because there’s no key and because that’s an allowed value.

So, Can You Check if Key Exist in the Record?

You can! Use the in operator to check if a key exists on your Record:

const userPoints: UserIdToPoints = {
  'alice@': 100,
  'bob@': undefined,
};

if ('alice@' in userPoints) {
  console.log('hit!'); // hit
}
if ('bob@' in userPoints) {
  console.log('hit!'); // hit, key exists despite value being undefined
}
if ('john@' in userPoints) {
  console.log('hit!'); // miss, the key does not exist
}

Record vs Map

But now it looks like we are losing some of the benefits of the Record type and replicating a Map. When dealing with an optionality of this kind, it’s worth considering if you should be using a Map instead. Both as a type and as a data structure, the Map may be better suited for the problem. It supports some of these use cases better out of the box because the "undefined" part is baked in. So, I’d recommend considering this instead:

type UserIdToPointsMap = Map<string, number>;

const userPointsMap = new Map() as UserIdToPointsMap;
userPointsMap.set('alice@', 100);
userPointsMap.set('bob@', 50);

const getPointsForUser = (map: UserIdToPointsMap, username: string): number | undefined => {
  return map.get(username);
}

You can do a similar thing with a WeakMap as well if you have some high-performance use cases that you want to optimize for.

How to Iterate a TypeScript Record

Finally, let’s cover how you can iterate through a Record. Again, it’s going to be a simple case of using the in operator via a for loop to get all the keys

type UserIdToPoints = Record<string, number | undefined>;

const userPoints: UserIdToPoints = {
  'alice@': 100,
  'bob@': 50,
};

for (const key in userPoints) {
  console.log(key, userPoints[key]);
}
// => 'alice@', 100
// => 'bob@', 50

Since it’s just an Object at the end of the day, you can also iterate the values and keys separately:

const users = Object.keys(userPoints); // ['alice@', 'bob@']
const points = Object.values(userPoints); // [100, 50]
Filip Danic

Filip Danic

Software Development Manager at Amazon and recovering JavaScript enthusiast. Sharing my experience and insights with the broader tech community via this blog!
The Netherlands