How to Use TypeScript Record<K, V> for Strongly Typed Objects
Record<K, V> 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<K, V> 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<K, V> offers clarity and structure to your code.
This article explains what Record<K, V> is, how it works, and when to use it.
What Is Record<K, V> in TypeScript?
In TypeScript, Record<K, V> is a utility type that constructs an object type where:
Krepresents the keys, which must extend string, number or symbol.Trepresents 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',
};
Common Pitfalls When Using TypeScript Record
While Record is powerful, there are several mistakes developers commonly make that can lead to unexpected bugs:
1. Assuming Non-Nullable Values with Record<string, Type>
The biggest trap with Record is assuming values are always defined. When you use Record<string, Type>, TypeScript doesn't protect you from undefined values:
const cache: Record<string, User> = {
user1: { id: 1, name: 'Alice', email: 'alice@example.com' }
};
// TypeScript thinks this is safe, but it's not!
const user = cache['nonexistent']; // user is undefined at runtime
console.log(user.name); // Runtime error: Cannot read property 'name' of undefined
When you access a property that doesn't exist, you get undefined, but TypeScript's type system assumes the value exists. This can crash your application.
Solution: Use optional chaining or create a safer alternative:
// Option 1: Use optional chaining
console.log(cache['nonexistent']?.name); // undefined (safe)
// Option 2: Use Map instead (recommended for dynamic keys)
const safeCache = new Map<string, User>();
safeCache.set('user1', { id: 1, name: 'Alice', email: 'alice@example.com' });
const user = safeCache.get('nonexistent'); // Type is User | undefined
if (user) {
console.log(user.name); // TypeScript enforces the check
}
// Option 3: Create a PartialRecord type
type PartialRecord<K extends string | number | symbol, V> = Partial<Record<K, V>>;
const partialCache: PartialRecord<string, User> = {
user1: { id: 1, name: 'Alice', email: 'alice@example.com' }
};
const maybeUser = partialCache['nonexistent']; // Type is User | undefined
2. Using Invalid Key Types
Record only accepts string, number, or symbol as key types. Trying to use other types will result in a TypeScript error:
// Error: Type 'boolean' does not satisfy the constraint 'string | number | symbol'
type BooleanMap = Record<boolean, string>; // Won't compile
// Error: Objects can't be used as keys
type ObjectMap = Record<{ id: number }, string>; // Won't compile
// Valid key types
type StringMap = Record<string, number>; // Works
type NumberMap = Record<number, string>; // Works
type SymbolMap = Record<symbol, boolean>; // Works
3. Forgetting Required Keys with Union Types
When using union types as keys, Record requires ALL keys to be present. Forgetting even one will cause a compile error:
type Status = 'pending' | 'approved' | 'rejected';
// Error: Property 'rejected' is missing
const statusMessages: Record<Status, string> = {
pending: 'Waiting for review',
approved: 'Request approved'
// Missing 'rejected'
};
If you want optional keys, combine Record with Partial:
const optionalMessages: Partial<Record<Status, string>> = {
pending: 'Waiting for review'
// Other keys are optional
};
4. Over-Complicating Simple Object Types
Don't use Record when a simple interface or type would be clearer:
// Overly complex
type Config = Record<'apiUrl' | 'timeout' | 'retries', string | number>;
// Much clearer
interface Config {
apiUrl: string;
timeout: number;
retries: number;
}
Use Record when you truly need uniform value types across all keys, not just for defining object shapes.
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<T>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 partialSettings2: Partial<Record<Theme, string>> = {
dark: '#000000',
};
- Pick vs. Record
Use
Pick<T, K>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[]>;
- Record vs. Index Signatures
When should you use Record over an index signature? It depends on whether your keys are known and fixed:
// Index signature - use when keys are truly dynamic
interface DynamicCache {
[key: string]: string;
}
const cache: DynamicCache = {
user123: 'userData',
session456: 'sessionData',
// Can add any string key at runtime
};
cache['newKey789'] = 'newData'; // Allowed
// Record with union - use when keys are known and fixed
type KnownKeys = 'user' | 'session' | 'preferences';
const typedCache: Record<KnownKeys, string> = {
user: 'userData',
session: 'sessionData',
preferences: 'prefsData'
};
// TypeScript enforces only these keys exist
typedCache['randomKey'] = 'data'; // Error: 'randomKey' is not assignable
When to use each:
- Index Signature (
{ [key: string]: Type }): When keys are truly unknown or dynamic, like user-generated IDs, API response data with variable fields, or plugin/extension systems - Record with Union (
Record<'key1' | 'key2', Type>): When keys are known at compile time, you want exhaustiveness checking, or you need better autocomplete in your IDE
Record also provides cleaner syntax and better readability:
// Verbose index signature with specific keys
const config1: { [key in 'api' | 'db' | 'cache']: string } = {
api: 'https://api.example.com',
db: 'postgres://localhost',
cache: 'redis://localhost'
};
// Cleaner with Record
const config2: Record<'api' | 'db' | 'cache', string> = {
api: 'https://api.example.com',
db: 'postgres://localhost',
cache: 'redis://localhost'
};
TypeScript Record vs Map: When to Use Each
While both Record and Map store key-value pairs, they're fundamentally different and serve different purposes. Understanding when to use each can significantly impact your code's performance and type safety.
Key Differences
| Feature | Record | Map |
|---|---|---|
| Type | TypeScript utility type (compiles to plain object) | JavaScript built-in class |
| Runtime | Plain JavaScript object | Map instance with methods |
| Key Types | string, number, symbol only | Any type (objects, functions, etc.) |
| Undefined Safety | Accessing missing keys returns undefined without warning | .get() explicitly returns T | undefined |
| Performance (reads) | Faster for small datasets | Faster for large datasets and frequent operations |
| Performance (mutations) | Slower for frequent add/delete | Optimized for frequent mutations |
| Iteration | Requires Object.entries(), Object.keys() | Built-in .forEach(), iterators |
| Size | Must calculate with Object.keys().length | Built-in .size property |
| Type Safety | Compile-time only | Runtime checks available |
When to Use Record
Use Record when:
// 1. Keys are known at compile time
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
const handlers: Record<HttpMethod, (req: Request) => Response> = {
GET: (req) => handleGet(req),
POST: (req) => handlePost(req),
PUT: (req) => handlePut(req),
DELETE: (req) => handleDelete(req)
};
// 2. Data structure is mostly static (few mutations)
type Config = 'apiUrl' | 'timeout' | 'retryLimit';
const appConfig: Record<Config, string | number> = {
apiUrl: 'https://api.example.com',
timeout: 5000,
retryLimit: 3
};
// 3. You need JSON serialization
const settings: Record<string, boolean> = {
darkMode: true,
notifications: false
};
JSON.stringify(settings); // Works perfectly
// 4. Working with small datasets (< 100 entries)
type UserRole = 'admin' | 'editor' | 'viewer';
const permissions: Record<UserRole, string[]> = {
admin: ['read', 'write', 'delete'],
editor: ['read', 'write'],
viewer: ['read']
};
When to Use Map
Use Map when:
// 1. Keys need to be non-primitive types
interface User {
id: number;
name: string;
}
const userPreferences = new Map<User, Settings>();
const user1 = { id: 1, name: 'Alice' };
userPreferences.set(user1, { theme: 'dark' }); // Objects as keys
// 2. Frequent additions and deletions
const activeConnections = new Map<string, WebSocket>();
// Adding and removing frequently
activeConnections.set('user123', websocket);
activeConnections.delete('user123'); // More efficient than Record
// 3. Large datasets with frequent lookups
const cache = new Map<string, CachedData>();
// Map is faster for large datasets
for (let i = 0; i < 10000; i++) {
cache.set(`key${i}`, { data: `value${i}` });
}
const result = cache.get('key5000'); // Faster lookup than Record
// 4. You need guaranteed iteration order
const orderedMap = new Map<number, string>();
orderedMap.set(3, 'three');
orderedMap.set(1, 'one');
orderedMap.set(2, 'two');
// Iterates in insertion order: 3, 1, 2
for (const [key, value] of orderedMap) {
console.log(key, value);
}
// 5. Better undefined handling
const safeCache = new Map<string, User>();
const maybeUser = safeCache.get('nonexistent'); // Type: User | undefined
if (maybeUser) {
console.log(maybeUser.name); // TypeScript enforces the check
}
Performance Considerations
For small, static configurations, Record performs slightly better:
// Good use of Record - small, static data
const httpStatusMessages: Record<number, string> = {
200: 'OK',
404: 'Not Found',
500: 'Internal Server Error'
};
For dynamic data with frequent mutations, Map wins:
// Good use of Map - dynamic, frequently changing
const sessionStore = new Map<string, SessionData>();
function addSession(id: string, data: SessionData) {
sessionStore.set(id, data); // Optimized for frequent writes
}
function removeSession(id: string) {
sessionStore.delete(id); // Faster than deleting from Record
}
function getSessionCount() {
return sessionStore.size; // Instant, vs Object.keys(record).length
}
Hybrid Approach: Convert When Needed
Sometimes you'll want to convert between them:
// Start with Record for type safety
type CacheKey = 'users' | 'products' | 'orders';
const initialCache: Record<CacheKey, string> = {
users: 'cached-users',
products: 'cached-products',
orders: 'cached-orders'
};
// Convert to Map for better runtime operations
const runtimeCache = new Map(Object.entries(initialCache));
// Now you get both: compile-time safety and runtime performance
runtimeCache.set('sessions', 'cached-sessions'); // Add dynamic keys
console.log(runtimeCache.size); // 4
Summary: Use Record for compile-time type safety with known keys and static data. Use Map for runtime flexibility, better performance with large/dynamic datasets, and when you need non-primitive keys.
Iterating Over TypeScript Records
Since Record creates regular JavaScript objects under the hood, you can iterate over them using standard object iteration methods. Here are the most common patterns:
Using Object.entries()
Object.entries() is the most common way to iterate over both keys and values simultaneously:
type ProductCategory = 'electronics' | 'clothing' | 'food';
type Stock = { count: number; location: string };
const inventory: Record<ProductCategory, Stock> = {
electronics: { count: 150, location: 'Warehouse A' },
clothing: { count: 300, location: 'Warehouse B' },
food: { count: 75, location: 'Warehouse C' }
};
// Iterate over entries
Object.entries(inventory).forEach(([category, stock]) => {
console.log(`${category}: ${stock.count} items in ${stock.location}`);
});
// With type-safe destructuring
for (const [category, stock] of Object.entries(inventory)) {
// TypeScript knows category is a string and stock is Stock
if (stock.count < 100) {
console.log(`Low stock alert for ${category}`);
}
}
Using Object.keys()
When you only need the keys, Object.keys() returns an array of key strings:
type Environment = 'development' | 'staging' | 'production';
const apiEndpoints: Record<Environment, string> = {
development: 'http://localhost:3000',
staging: 'https://staging.example.com',
production: 'https://api.example.com'
};
// Get all environment names
const environments = Object.keys(apiEndpoints);
console.log(environments); // ['development', 'staging', 'production']
// Access values using keys
Object.keys(apiEndpoints).forEach((env) => {
const endpoint = apiEndpoints[env as Environment];
console.log(`${env}: ${endpoint}`);
});
Note: Object.keys() returns string[], not the specific union type, so you may need to cast when accessing values.
Using Object.values()
When you only care about values and can ignore the keys:
type Theme = 'light' | 'dark' | 'auto';
const themeColors: Record<Theme, string> = {
light: '#ffffff',
dark: '#000000',
auto: '#808080'
};
// Get all color values
const colors = Object.values(themeColors);
console.log(colors); // ['#ffffff', '#000000', '#808080']
// Check if a color exists
const hasWhite = Object.values(themeColors).includes('#ffffff'); // true
Converting Records to Maps
If you need Map methods or better iteration performance, you can convert a Record to a Map:
type CacheKey = 'user' | 'session' | 'preferences';
const recordCache: Record<CacheKey, string> = {
user: 'userData',
session: 'sessionData',
preferences: 'preferencesData'
};
// Convert to Map for better iteration
const mapCache = new Map(Object.entries(recordCache));
// Now you can use Map methods
mapCache.forEach((value, key) => {
console.log(`${key}: ${value}`);
});
console.log(mapCache.has('user')); // true
console.log(mapCache.get('session')); // 'sessionData'
Real-World Example: Type-Safe CMS with Convex
If you're using Convex for your backend, Record types integrate seamlessly with Convex's type-safe database schema and function arguments. The v.record() validator allows you to define strict key-value mappings in your database schema. Here's how you might build a content management system where articles have different statuses, and Record ensures type safety across your entire stack:
import { mutation, query } from "./_generated/server";
import { v, Infer } from "convex/values";
// Define our status types
const articleStatus = v.union(
v.literal("draft"),
v.literal("published"),
v.literal("archived"),
);
type ArticleStatus = Infer<typeof articleStatus>;
// Define status configurations
interface StatusConfig {
displayName: string;
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"), articleStatus) },
handler: async (ctx, { articles }) => {
for (const [articleId, newStatus] of 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 how Record provides type safety when building a content management system with Convex, ensuring that status transitions are validated at both compile time and runtime.
Final Thoughts
The Record utility type in TypeScript is a powerful tool for creating type-safe object mappings. It shines when:
- Working with predefined sets of keys that you know at compile time
- Ensuring consistent value types across all properties
- Building configuration objects and lookup tables
- Catching errors early with exhaustive type checking
Remember the key rules:
- Use Record with union keys for maximum type safety and IDE support
- Watch out for undefined values with
Record<string, Type>- consider using Map or optional chaining - Choose Map over Record when you need frequent mutations, large datasets, or non-primitive keys
- Prefer index signatures only when keys are truly dynamic and unknown
Whether you're building API configurations, state machines, or data mappings, Record helps keep your TypeScript code clean, safe, and maintainable.