A Guide to The TypeScript Record Type

filipdanic

Filip Danić

Posted on April 9, 2024

A Guide to The TypeScript Record Type

TypeScript Record Example

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,
};

Enter fullscreen mode Exit fullscreen mode

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

Now, let’s look at how the type safety kicks in. For starters, the Record will expect total coverage of our chose 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)
Enter fullscreen mode Exit fullscreen mode

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

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 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 of 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 out first example was a broader in the scope of the Record’s Key type. Since we chose a generic string there’s no exhaustive checking that could happen. And 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!
Enter fullscreen mode Exit fullscreen mode

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 error 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 choose to modify their 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;
}
Enter fullscreen mode Exit fullscreen mode

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 pitfall of UserIdToPoints? Imagine that they use a function in their code getPointsMap which 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

Enter fullscreen mode Exit fullscreen mode

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

Record vs Map

But now it looks like were are losing some of the benefits of the Record type and replicating a Map. When dealing with a optionality of this kind, it’s worth to consider 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);
}
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Cross-posted from my blog: The Software Lounge – A Guide to The TypeScript Record Type

💖 💪 🙅 🚩
filipdanic
Filip Danić

Posted on April 9, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related