TypeScript Record Type: Dictionary Patterns
TypeScript's `Record<K, V>` utility type creates an object type with keys of type `K` and values of type `V`. It's syntactic sugar for `{ [key in K]: V }`, but with clearer intent and better...
Key Insights
Record<K, V>provides compile-time type safety for dictionary objects, catching key typos and value type mismatches that plain objects miss- Combining Record with union types and enums creates exhaustive mappings that TypeScript validates at compile time, eliminating entire classes of runtime errors
- For dynamic data or runtime key operations, Map often outperforms Record, but Record excels for static configurations and type-level guarantees
Introduction to Record Type
TypeScript’s Record<K, V> utility type creates an object type with keys of type K and values of type V. It’s syntactic sugar for { [key in K]: V }, but with clearer intent and better readability.
The fundamental difference between Record and plain objects is type enforcement. A plain object typed as { [key: string]: number } allows any string key, but Record can constrain keys to specific literals or unions:
// Basic Record with string keys and number values
const userAges: Record<string, number> = {
alice: 30,
bob: 25,
charlie: 35
};
// TypeScript catches type errors
userAges.diana = 28; // ✓ Valid
userAges.eve = "thirty"; // ✗ Error: Type 'string' is not assignable to type 'number'
Compared to Map, Record offers better ergonomics for static data. Maps require .get() and .set() method calls, while Records use standard property access. However, Maps provide better runtime performance for frequent additions/deletions and support non-string keys natively.
Common Dictionary Patterns
The most straightforward pattern uses string keys for lookups where you know the domain of possible values.
// Translation dictionary
type Locale = 'en' | 'es' | 'fr';
type TranslationKey = 'welcome' | 'goodbye' | 'thanks';
const translations: Record<Locale, Record<TranslationKey, string>> = {
en: {
welcome: 'Welcome',
goodbye: 'Goodbye',
thanks: 'Thank you'
},
es: {
welcome: 'Bienvenido',
goodbye: 'Adiós',
thanks: 'Gracias'
},
fr: {
welcome: 'Bienvenue',
goodbye: 'Au revoir',
thanks: 'Merci'
}
};
Enum-based dictionaries create exhaustive mappings. TypeScript ensures you’ve handled every enum member:
enum HttpStatus {
OK = 200,
Created = 201,
BadRequest = 400,
Unauthorized = 401,
NotFound = 404,
ServerError = 500
}
const statusMessages: Record<HttpStatus, string> = {
[HttpStatus.OK]: 'Request successful',
[HttpStatus.Created]: 'Resource created',
[HttpStatus.BadRequest]: 'Invalid request',
[HttpStatus.Unauthorized]: 'Authentication required',
[HttpStatus.NotFound]: 'Resource not found',
[HttpStatus.ServerError]: 'Internal server error'
// Omitting any status causes a compile error
};
Feature flags demonstrate union type keys for constrained dictionaries:
type FeatureFlag = 'darkMode' | 'betaFeatures' | 'analytics';
const featureFlags: Record<FeatureFlag, boolean> = {
darkMode: true,
betaFeatures: false,
analytics: true
};
Advanced Type Constraints
Template literal types enable dynamic property names with type safety:
type EventName = 'click' | 'hover' | 'focus';
type EventHandler = `on${Capitalize<EventName>}`;
const handlers: Record<EventHandler, () => void> = {
onClick: () => console.log('clicked'),
onHover: () => console.log('hovered'),
onFocus: () => console.log('focused')
};
Partial<Record<K, V>> handles optional entries elegantly:
type ConfigKey = 'apiUrl' | 'timeout' | 'retries' | 'debug';
// Not all config values required
const userConfig: Partial<Record<ConfigKey, string | number | boolean>> = {
apiUrl: 'https://api.example.com',
debug: true
// timeout and retries omitted
};
Combining Record with utility types creates powerful patterns:
interface User {
id: string;
name: string;
email: string;
password: string;
}
// Readonly configuration object
const defaultUsers: Readonly<Record<string, Omit<User, 'password'>>> = {
admin: {
id: '1',
name: 'Admin User',
email: 'admin@example.com'
},
guest: {
id: '2',
name: 'Guest User',
email: 'guest@example.com'
}
};
// Nested Records for complex structures
type Permission = 'read' | 'write' | 'delete';
type Resource = 'posts' | 'users' | 'comments';
const permissions: Record<string, Record<Resource, Permission[]>> = {
admin: {
posts: ['read', 'write', 'delete'],
users: ['read', 'write', 'delete'],
comments: ['read', 'write', 'delete']
},
editor: {
posts: ['read', 'write'],
users: ['read'],
comments: ['read', 'write', 'delete']
}
};
Type-Safe CRUD Operations
Creating helper functions for dictionary operations maintains type safety throughout your codebase:
// Type-safe getter with default value
function getOrDefault<K extends string, V>(
dict: Record<K, V>,
key: K,
defaultValue: V
): V {
return dict[key] ?? defaultValue;
}
// Safe update that preserves types
function updateEntry<K extends string, V>(
dict: Record<K, V>,
key: K,
value: V
): Record<K, V> {
return { ...dict, [key]: value };
}
// Type-safe deletion
function deleteEntry<K extends string, V>(
dict: Record<K, V>,
key: K
): Partial<Record<K, V>> {
const { [key]: _, ...rest } = dict;
return rest;
}
// Usage
type UserRole = 'admin' | 'editor' | 'viewer';
const roles: Record<string, UserRole> = {
alice: 'admin',
bob: 'editor'
};
const defaultRole = getOrDefault(roles, 'charlie', 'viewer');
const updatedRoles = updateEntry(roles, 'bob', 'admin');
const withoutAlice = deleteEntry(roles, 'alice');
Safe property access prevents runtime errors:
function safeGet<K extends string, V>(
dict: Partial<Record<K, V>>,
key: K
): V | undefined {
return dict[key];
}
// Type guard for checking existence
function hasKey<K extends string>(
dict: Partial<Record<K, unknown>>,
key: K
): key is K {
return key in dict;
}
const config: Partial<Record<ConfigKey, number>> = {
timeout: 5000
};
if (hasKey(config, 'timeout')) {
const timeout: number = config.timeout; // Type narrowed, no undefined
}
Real-World Use Cases
Redux-style state management benefits from Record’s type safety:
type ActionType = 'ADD_TODO' | 'REMOVE_TODO' | 'TOGGLE_TODO';
interface Action {
type: ActionType;
payload: any;
}
interface State {
todos: Array<{ id: string; text: string; done: boolean }>;
}
const reducers: Record<ActionType, (state: State, action: Action) => State> = {
ADD_TODO: (state, action) => ({
...state,
todos: [...state.todos, action.payload]
}),
REMOVE_TODO: (state, action) => ({
...state,
todos: state.todos.filter(todo => todo.id !== action.payload)
}),
TOGGLE_TODO: (state, action) => ({
...state,
todos: state.todos.map(todo =>
todo.id === action.payload ? { ...todo, done: !todo.done } : todo
)
})
};
function reducer(state: State, action: Action): State {
return reducers[action.type](state, action);
}
API response caching with entity normalization:
interface Post {
id: string;
title: string;
authorId: string;
}
interface Author {
id: string;
name: string;
}
interface NormalizedCache {
posts: Record<string, Post>;
authors: Record<string, Author>;
}
const cache: NormalizedCache = {
posts: {},
authors: {}
};
function cachePost(post: Post, author: Author): void {
cache.posts[post.id] = post;
cache.authors[author.id] = author;
}
function getPostWithAuthor(postId: string): (Post & { author: Author }) | null {
const post = cache.posts[postId];
if (!post) return null;
const author = cache.authors[post.authorId];
if (!author) return null;
return { ...post, author };
}
Common Pitfalls and Solutions
Index signatures allow any key, while Record constrains them. Choose based on your needs:
// Index signature - any string key allowed
interface LooseConfig {
[key: string]: string;
}
// Record - only specific keys allowed
type StrictConfig = Record<'apiKey' | 'endpoint', string>;
const loose: LooseConfig = { anything: 'goes' }; // ✓
const strict: StrictConfig = { apiKey: 'key', endpoint: 'url' }; // ✓
// const strict2: StrictConfig = { anything: 'goes' }; // ✗ Error
Handle undefined values with proper type narrowing:
type Cache = Partial<Record<string, string>>;
function getCached(cache: Cache, key: string): string {
const value = cache[key];
// Option 1: Nullish coalescing
return value ?? 'default';
// Option 2: Type guard
if (value === undefined) {
throw new Error(`Key ${key} not found`);
}
return value;
}
For large dictionaries with frequent updates, consider Map for better performance:
// Record - good for static data
const staticConfig: Record<string, number> = { /* ... */ };
// Map - better for dynamic data
const dynamicCache = new Map<string, number>();
dynamicCache.set('key', 100);
dynamicCache.delete('key');
Best Practices Summary
Use Record when you need compile-time key constraints and static data structures. Reach for Map when dealing with dynamic keys, frequent mutations, or non-string keys. For complex objects with different value types per property, use interfaces instead.
Name dictionary types descriptively: UserRoleMap, StatusCodeDictionary, TranslationTable. This clarifies intent and distinguishes them from other type aliases.
Test Record-based code by verifying type constraints at compile time and runtime behavior separately. TypeScript’s type system handles the former; your unit tests should focus on the logic:
// Type-level test (compile-time)
const test: Record<'a' | 'b', number> = { a: 1, b: 2 }; // Must compile
// Runtime test
expect(getOrDefault(test, 'a', 0)).toBe(1);
expect(getOrDefault(test, 'c' as any, 0)).toBe(0);
Quick reference: Use Record for type-safe dictionaries with known keys, Partial Record for optional entries, combine with utility types for advanced constraints, and always provide type-safe accessor functions for external APIs.