Skip to main content

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:

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

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.

Record serves a specific purpose, but it's essential to know when to use it over other TypeScript utilities:

  1. 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',
};
  1. 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[]>;
  1. 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

FeatureRecordMap
TypeTypeScript utility type (compiles to plain object)JavaScript built-in class
RuntimePlain JavaScript objectMap instance with methods
Key Typesstring, number, symbol onlyAny type (objects, functions, etc.)
Undefined SafetyAccessing missing keys returns undefined without warning.get() explicitly returns T | undefined
Performance (reads)Faster for small datasetsFaster for large datasets and frequent operations
Performance (mutations)Slower for frequent add/deleteOptimized for frequent mutations
IterationRequires Object.entries(), Object.keys()Built-in .forEach(), iterators
SizeMust calculate with Object.keys().lengthBuilt-in .size property
Type SafetyCompile-time onlyRuntime 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:

  1. Use Record with union keys for maximum type safety and IDE support
  2. Watch out for undefined values with Record<string, Type> - consider using Map or optional chaining
  3. Choose Map over Record when you need frequent mutations, large datasets, or non-primitive keys
  4. 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.