How to Use TypeScript Record
for Strongly Typed Objects
Record
is a utility type in TypeScript that helps enforce consistency by making sure that all key-value pairs in an object adhere to uniform types. This makes it easier to work with predictable data structures, especially when dealing with keys defined by a union type.
Using Record
brings several benefits:
- Type safety for object properties and values
- Improved code maintainability through explicit typing
- Better IDE support with accurate autocompletion
- Clear communication of data structure intentions
Whether you're building a dictionary, a configuration object, or mapping IDs to data, Record
offers clarity and structure to your code.
This article explains what Record
is, how it works, and when to use it.
What Is Record
in TypeScript?
In TypeScript, Record
is a utility type that constructs an object type where:
K
represents the keys, which must extend string, number or symbol.T
represents the type of the values associated with those keys.
The Record type maps keys to values while ensuring both key and value types are consistent. This prevents runtime errors and makes refactoring easier in larger projects. For example, if you have a set of predefined roles in your application and you want to associate each role with specific permissions, Record ensures your object stays consistent:
type Role = 'admin' | 'editor' | 'viewer';
type Permission = 'read' | 'write' | 'delete';
type Permissions = ('read' | 'write' | 'delete')[];
const rolePermissions: Record<Role, Permissions> = {
admin: ['read', 'write', 'delete'],
editor: ['read', 'write'],
viewer: ['read'],
};
In this example:
- The Role type union defines all possible keys
- Each key must map to an array of permissions
- TypeScript will enforce that all roles are present
- TypeScript will ensure only valid permissions are assigned
If you try to skip a role or use an invalid permission, TypeScript will show an error, helping catch potential issues during development.
Using Record for Predictable Object Structures
1. Mapping User IDs to User Objects
When managing collections of users, Record can be used to map unique user IDs to detailed user objects. This approach lets you retrieve user data by ID, while maintaining strict typing for both keys and values:
interface User {
id: number;
name: string;
email: string;
}
const userMap: Record<number, User> = {
1: { id: 1, name: "Alice", email: "alice@example.com" },
2: { id: 2, name: "Bob", email: "bob@example.com" },
};
console.log(userMap[1].name); // Output: "Alice"
2. Creating a Configuration Object
When dealing with application settings or configurations, Record is great for defining structured mappings between predefined keys and their respective values. Here’s how to set up a basic configuration object:
type ConfigKeys = "theme" | "language" | "notifications";
const appConfig: Record<ConfigKeys, string> = {
theme: "dark",
language: "en",
notifications: "enabled",
};
console.log(appConfig.theme); // Output: "dark"
3. Mapping Configuration Values
Use Record to define a configuration object where keys are predefined categories, and values are uniform settings:
type ConfigKeys = 'theme' | 'language' | 'timezone';
type ConfigValue = string;
const appConfig: Record<ConfigKeys, ConfigValue> = {
theme: 'dark',
language: 'en',
timezone: 'UTC',
};
4. Dynamic Component Styling
Define a style mapping where each key represents a component, and values represent its styles:
type Components = 'button' | 'card' | 'header';
type Style = { color: string; fontSize: string };
const componentStyles: Record<Components, Style> = {
button: { color: 'blue', fontSize: '14px' },
card: { color: 'white', fontSize: '16px' },
header: { color: 'black', fontSize: '20px' },
};
5. Error Message Dictionaries
Create an object to map error codes to user-friendly messages:
type ErrorCode = '404' | '500' | '403';
const errorMessages: Record<ErrorCode, string> = {
'404': 'Page not found',
'500': 'Internal server error',
'403': 'Access forbidden',
};
How Record Compares to Other Object-Related TypeScript Utilities
Record serves a specific purpose, but it’s essential to know when to use it over other TypeScript utilities:
- Partial vs. Record Use Partial when you want to make all properties in an object type optional, while Record is better when you need to enforce all keys and value types:
// With Partial - properties are optional
type Settings = { theme: string; language: string };
const partialSettings: Partial<Settings> = { theme: 'dark' }; // Valid
// With Record - all keys required
type Theme = 'dark' | 'light';
const settings: Record<Theme, string> = {
dark: '#000000',
light: '#ffffff'
}; // Must include both keys
// With both Partial and Record, the key type is enforced
// but you don’t have to specify every key
const settings: Partial<Record<Theme, string>> = {
dark: '#000000',
};
- Pick vs. Record Use Pick to extract a subset of keys from an existing type, while Record is for creating a brand-new type:
interface User {
id: number;
name: string;
email: string;
age: number;
}
// With Pick - select subset of existing properties
const userSummary: Pick<User, 'id' | 'name'> = {
id: 0,
name: 'Alex'
};
// With Record - create new mapping structure
type UserRoles = Record<'admin' | 'user' | guest', string[]>;
// With both Pick and Record, you can enforce that
// a subset of keys are present
const basicRoles = Pick<UserRoles, 'user' | guest'>;
/ Select only specific keys
- Direct Object Type Definition vs.
Record
Record provides better type safety than index signatures:
// Without Record
const userRoles: { [key: string]: string } = { admin: 'read-write', guest: 'read-only' };
// With Record
const userRolesWithRecord: Record<'admin' | 'guest', string> = { admin: 'read-write', guest: 'read-only' };
How Convex Ties Into Record
Convex makes it easy to manage structured data in backend services, including Record types in the database and function arguments. With v.record, you can:
- Define strict mappings for your database schema or backend logic
- Ensure type consistency across your application.
- Provide a type-safe backend for handling structured data like user preferences or mappings
- Support schema flexibility with Convex's adaptable schema to ensure type safety as requirements change.
Using Record in Schema Definitions
When defining your Convex database schema, Record helps create type-safe mappings for document fields:
import { v } from "convex/values";
const permissions = v.union(
v.literal('read'),
v.literal('write'),
v.literal('manage'),
);
export default defineSchema({
blogPost: {
permissions: v.record(v.id('users'), permissions),
content: v.string(),
},
users: {
v.object({
name: v.string(),
email: v.string(),
},
});
Using Record in Convex for Backend Data
In a Convex function, you might use Record
to enforce type safety when handling key-value mappings in your database.
import { mutation } from "./convex/_generated/server";
export const createUserPreferences = mutation({
args: {
preferences: v.record(v.string(), v.string(),
},
handler: async (ctx, args) => {
const defaultreferences: Record<string, string> = {
darkMode: "enabled",
language: "English",
notifications: "on",
};
await db.insert("userPreferences", {
...defaultPreferences,
...preferences
});
});
Real-World Example
Imagine building a CMS where each article has a status like "draft," "published," or "archived." Using Record, you can map these statuses to specific configurations, ensuring consistent behavior across your application.
import { mutation, query } from "./_generated/server";
import { v, Infer } from "convex/values";
// Define our status types
constt articleStatus = v.union(
v.literal("draft"),
v.literal("published"),
v.literal("archived"),
);
// Define status configurations
interface StatusConfig {
status: Infer<typeof articleStatus>;
allowedTransitions: ArticleStatus[];
userRoles: string[];
}
// Create type-safe status mappings
const articleStatusConfig: Record<ArticleStatus, StatusConfig> =
draft: {
displayName: "Draft",
allowedTransitions: ["published", "archived"],
userRoles: ["editor", "admin"]
},
published: {
displayName: "Published",
allowedTransitions: ["draft", "archived"],
userRoles: ["admin"]
},
archived: {
displayName: "Archived",
allowedTransitions: ["draft"],
userRoles: ["admin"]
}
};
// Convex mutation to update status for many articles
export const updateArticleStatus = mutation({
args: { articles: v.record(v.id("articles"), newStatus: articleStatus) },
handler: async (ctx, { articles }) => {
for (const [article, newStatus] in Object.entries(articles)) {
const article = await ctx.db.get(articleId);
if (!article) throw new Error("Article not found");
if (
!articleStatusConfig[article.status].allowedTransitions.includes(
newStatus
)
) {
throw new Error("Invalid status");
}
await ctx.db.patch(articleId, { status: newStatus });
}
},
});
This example demonstrates using Record with Convex to:
- Maintain type safety between frontend and backend
- Handle real-time updates with confidence
- Ensure data consistency in your database
Final Thoughts
The Record utility type in TypeScript is a useful tool for managing consistent, type-safe mappings. It's especially powerful when:
- Working with predefined sets of keys
- Ensuring consistent value types across objects
- Building scalable lookup tables
- Integrating with backend systems like Convex
Whether you're building lookup tables, managing configurations, or writing backend logic, Record helps keep your code clean and maintainable. Paired with Convex, it simplifies handling real-time, type-safe data.