A Guide to The TypeScript Record Type
Filip Danić
Posted on April 9, 2024
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 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)
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 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!
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;
}
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
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 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);
}
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]
Cross-posted from my blog: The Software Lounge – A Guide to The TypeScript Record Type
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
November 30, 2024