A Guide to The TypeScript Record Type
Tags: typescript
Published: 2024-04-09 20:20:06

---
title: A Guide to The TypeScript Record Type
published: true
date: 2024-03-24 14:12:52 UTC
tags: TypeScript
canonical_url: https://www.thesoftwarelounge.com/typescript-record-type-guide/
---
![TypeScript Record Example](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ulzbioebtr0sv1xideu3.jpg)

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.

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

```typescript
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 chose key type. If I forget to add `member`, we get a type error.

```typescript
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:

```typescript
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 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:

```typescript
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 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:

```typescript
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 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:

```typescript
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:

```typescript
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 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:

```typescript
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

```typescript
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:

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

Cross-posted from my blog: [The Software Lounge – A Guide to The TypeScript Record Type](https://www.thesoftwarelounge.com/typescript-record-type-guide/)