TypeScript Record<K, V> vs Map<K, V>: Choosing the Right Structure When Types Matter
Most TypeScript developers reach for Record by default, but Map provides runtime guarantees that static types cannot. Learn when each structure prevents bugs in production.
TypeScript Record<K, V> vs Map<K, V>: Choosing the Right Structure When Types Matter
Most TypeScript data structure problems stem from conflating compile-time shape validation with runtime key management. Teams reach for Record<K, V> when they need predictable property access and reach for Map<K, V> when keys arrive dynamically. The distinction matters because choosing the wrong structure introduces silent failures that types cannot catch.
The failure mode appears when developers treat Record as a general-purpose key-value store. A configuration object with known string keys works perfectly as a Record<string, Config>. The compiler enforces exhaustiveness checks, object spread works as expected, and JSON serialization is trivial. But when those keys come from user input or API responses, the Record type system breaks down. TypeScript assumes the key exists, but at runtime it returns undefined. The application crashes or silently corrupts data.
flowchart LR
A("User input key") --> B("Record access")
B --> C("Compiler assumes key exists")
C --> D("Runtime returns undefined")
D --> E("Silent failure or crash")
style E stroke:#ef4444,fill:#450a0a,color:#fca5a5
The correct approach recognizes that Record and Map solve different problems. Record enforces a static schema where the compiler validates every key access. Map provides runtime guarantees for dynamic keys with methods that make absence explicit. When keys are known at compile time and the shape rarely changes, Record prevents entire categories of bugs. When keys arrive at runtime or the structure mutates frequently, Map prevents the exact failures that Record types hide.
flowchart LR
A("User input key") --> B("Map.get(key)")
B --> C("Returns value | undefined")
C --> D("Developer handles absence")
D --> E("Safe fallback or error")
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Key Takeaways
Record<K, V>enforces compile-time key validation but assumes all keys exist at runtime, leading to silentundefinedaccess bugs when keys are dynamic.Map<K, V>provides runtime guarantees for key existence through explicit.has()and.get()methods, preventing silent failures with dynamic data.- Static configuration objects with known keys benefit from
Recordbecause the compiler enforces exhaustiveness and enables object spread operations. - Cache layers and user-driven data structures require
Mapbecause keys arrive unpredictably and frequent additions/deletions demand efficient runtime operations. - Converting between structures is straightforward but each direction loses critical guarantees,
RecordtoMaploses compile-time validation,MaptoRecordloses runtime safety.
When Record<K, V> Shines: Compile-Time Keys and Static Shapes
Record<K, V> enforces type safety when the set of keys is known before runtime. The compiler validates that every key access matches the declared type and catches typos during development. This structure works best for configuration objects, API response shapes, and any data where the schema stays fixed.
A configuration object demonstrates the pattern. An application defines feature flags as a Record<FeatureName, boolean> where FeatureName is a union of string literals. The compiler rejects access to undefined features and enforces exhaustiveness when iterating over all flags. Teams cannot accidentally check a non-existent flag or forget to handle a newly added feature.
flowchart TD
A("FeatureName union type") --> B("Record declaration")
B --> C("Compiler validates key access")
C --> D("Exhaustiveness check enforced")
D --> E("Type-safe feature flags")
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style C stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
The type system catches errors before they reach production:
type FeatureName = 'darkMode' | 'analytics' | 'experimental';
interface FeatureConfig {
enabled: boolean;
rolloutPercentage: number;
}
const features: Record<FeatureName, FeatureConfig> = {
darkMode: { enabled: true, rolloutPercentage: 100 },
analytics: { enabled: false, rolloutPercentage: 0 },
experimental: { enabled: true, rolloutPercentage: 10 },
};
// Compiler error: Property 'invalidFeature' does not exist
const invalid = features.invalidFeature;
// Exhaustiveness check forces handling all features
type FeatureCheck = {
[K in FeatureName]: (config: FeatureConfig) => void;
};The JSON serialization advantage matters for persistence and network transport. Record is a plain JavaScript object that serializes directly with JSON.stringify. No custom serialization logic or prototype pollution concerns. Deserialization produces the same structure with the same prototype chain.
Object spread and destructuring work naturally with Record. Developers merge configurations with { ...defaults, ...overrides } and extract specific features with const { darkMode, analytics } = features. These operations fail or require manual iteration with Map.
The limitation appears when keys become unpredictable. A user ID lookup table defined as Record<string, User> compiles without warnings but crashes at runtime when accessing a non-existent ID. The type system cannot verify that every possible string key has a corresponding value. This is where Map provides safety that types cannot.
When Map<K, V> Is Superior: Runtime Keys and Frequent Mutations
Map<K, V> provides runtime guarantees that static types cannot enforce. The .has() method returns an explicit boolean for key existence, and .get() returns undefined for missing keys without assuming they exist. This structure prevents the silent failures that plague Record when keys arrive dynamically.
Cache implementations demonstrate the pattern. A response cache stores fetch results by URL, but the set of cached URLs grows and shrinks during application lifetime. Using Record<string, Response> requires developers to remember that cache[url] might be undefined despite the type signature. Using Map<string, Response> makes absence explicit through the API.
flowchart TD
A("Dynamic cache keys") --> B("Map.set during fetch")
B --> C("Map.has checks existence")
C --> D{"Key exists?"}
D -->|Yes| E("Map.get returns value")
D -->|No| F("Map.get returns undefined")
E --> G("Type-safe cache hit")
F --> H("Explicit cache miss")
style G stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style H stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style C stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
The difference in iteration matters for performance. Map provides .forEach(), .keys(), .values(), and .entries() methods that iterate in insertion order. Developers iterate without Object.keys() or prototype chain concerns. Clearing all entries takes a single .clear() call instead of iterating and deleting properties.
class ResponseCache {
private cache = new Map<string, Response>();
private maxSize = 100;
set(url: string, response: Response): void {
if (this.cache.size >= this.maxSize) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
this.cache.set(url, response);
}
get(url: string): Response | undefined {
return this.cache.get(url);
}
has(url: string): boolean {
return this.cache.has(url);
}
clear(): void {
this.cache.clear();
}
}The size property provides an O(1) count of entries. Record requires Object.keys(record).length which iterates every property. For structures that check size frequently, this performance difference compounds.
Non-string keys unlock patterns that Record cannot support. Map accepts objects, symbols, and numbers as keys. A component instance cache keyed by DOM elements or a dependency graph keyed by class constructors requires Map. Attempting these patterns with Record forces key serialization that loses identity semantics.
The prototype pollution protection matters for security-sensitive applications. Record inherits from Object.prototype, so accessing record.toString or record.hasOwnProperty returns inherited methods instead of stored values. Map has no prototype chain issues because keys and methods occupy separate namespaces.
Type Safety Patterns: Record vs Map in Practice
The type system enforces different guarantees depending on structure choice. Record provides compile-time exhaustiveness checks for union types but assumes keys exist at runtime. Map provides runtime existence checks but loses compile-time validation of key membership.
A strict configuration pattern combines both structures. The application defines a Record for the canonical configuration shape and uses Map for runtime overrides. This approach preserves compile-time validation while supporting dynamic keys.
type ConfigKey = 'apiUrl' | 'timeout' | 'retries';
interface ConfigValue {
value: string | number;
source: 'default' | 'env' | 'runtime';
}
const defaultConfig: Record<ConfigKey, ConfigValue> = {
apiUrl: { value: 'https://api.example.com', source: 'default' },
timeout: { value: 5000, source: 'default' },
retries: { value: 3, source: 'default' },
};
class ConfigManager {
private overrides = new Map<string, ConfigValue>();
get<K extends ConfigKey>(key: K): ConfigValue {
// Check runtime overrides first
const override = this.overrides.get(key);
if (override) return override;
// Fall back to compile-time validated defaults
return defaultConfig[key];
}
setOverride(key: string, value: ConfigValue): void {
this.overrides.set(key, value);
}
clearOverrides(): void {
this.overrides.clear();
}
}The discriminated union pattern strengthens type safety for heterogeneous values. Instead of Record<string, unknown>, developers define a union of specific key-value pairs. This preserves type information for each key while maintaining the Record structure.
type ConfigEntry =
| { key: 'apiUrl'; value: string }
| { key: 'timeout'; value: number }
| { key: 'retries'; value: number };
type Config = {
[K in ConfigEntry['key']]: Extract<ConfigEntry, { key: K }>['value'];
};
const config: Config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3,
};The mapped type ensures that each key receives its corresponding value type. Assigning config.timeout = "5000" produces a compiler error because the type system knows timeout requires a number.
For structures with optional keys, Record and Map diverge further. Record<string, T | undefined> makes every value possibly undefined but provides no runtime existence check. Map<string, T> guarantees that retrieved values are either T or explicitly undefined from .get().
Performance Comparison: Iteration, Lookup, and Memory
Performance characteristics differ between Record and Map in ways that matter for large datasets and frequent operations. Lookup performance favors Record for small key sets and Map for large or dynamically changing collections. Iteration performance strongly favors Map. Memory overhead depends on key types and mutation frequency.
flowchart LR
subgraph Record["Record performance"]
A("Small static keys") --> B("Fast property access")
B --> C("Slow iteration")
C --> D("Minimal memory overhead")
end
subgraph Map["Map performance"]
E("Large dynamic keys") --> F("Hash-based lookup")
F --> G("Fast iteration")
G --> H("Higher memory overhead")
end
style B stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style G stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style C stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style H stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
Lookup performance for Record relies on V8's hidden classes and inline caches. When the key set stays fixed and property access patterns repeat, the engine optimizes property access to near-native speed. Adding or deleting properties invalidates these optimizations and forces slower dictionary mode access.
Map uses a hash table implementation that maintains consistent O(1) lookup regardless of mutation frequency. For applications that add and remove keys frequently, Map avoids the performance cliffs that come from transitioning between optimized and dictionary mode in plain objects.
Iteration performance strongly favors Map because it maintains insertion order natively. Iterating a Record requires Object.keys(), Object.values(), or Object.entries(), each allocating a new array and iterating the prototype chain. Map provides iterator methods that produce values directly without intermediate allocations.
// Record iteration allocates intermediate arrays
const recordKeys = Object.keys(record);
for (const key of recordKeys) {
const value = record[key];
}
// Map iteration uses native iterators
for (const [key, value] of map) {
// Direct access without array allocation
}Memory overhead measurements show that Map consumes more memory per entry than Record for small datasets. The hash table structure and iterator support add bookkeeping overhead. For datasets under 100 entries, Record uses 20-30% less memory. Beyond 1000 entries, the difference becomes negligible as the relative overhead shrinks.
Deletion performance matters for cache implementations and other structures with frequent turnover. Deleting a property from a Record with the delete operator deoptimizes the object and forces it into dictionary mode. Map.delete() maintains consistent performance because the hash table structure expects deletions.
The size property difference affects applications that track collection length. Map.size reads a maintained counter in O(1) time. Computing Object.keys(record).length iterates all enumerable properties, making it O(n). For applications that check size in hot code paths, this difference compounds.
Real-World Use Cases: Configuration Objects vs Cache Layers
Configuration objects with known keys benefit from Record because the compiler enforces schema adherence and enables type-safe access. A feature flag system defines all flags as a union type, uses Record to map flags to configuration, and gains exhaustiveness checking when adding new flags.
type FeatureFlag =
| 'enableBetaFeatures'
| 'useNewPaymentFlow'
| 'showExperimentalUI';
interface FlagConfig {
enabled: boolean;
allowedRoles: string[];
rolloutPercentage: number;
}
const featureFlags: Record<FeatureFlag, FlagConfig> = {
enableBetaFeatures: {
enabled: true,
allowedRoles: ['admin', 'beta-tester'],
rolloutPercentage: 100,
},
useNewPaymentFlow: {
enabled: false,
allowedRoles: ['admin'],
rolloutPercentage: 0,
},
showExperimentalUI: {
enabled: true,
allowedRoles: ['admin'],
rolloutPercentage: 25,
},
};
function isFlagEnabled(
flag: FeatureFlag,
userRole: string,
userId: string
): boolean {
const config = featureFlags[flag];
if (!config.enabled) return false;
if (!config.allowedRoles.includes(userRole)) return false;
const userHash = hashUserId(userId);
return userHash % 100 < config.rolloutPercentage;
}Cache layers with dynamic keys require Map because keys arrive from runtime data and the structure mutates frequently. A request cache stores responses by URL but cannot predict which URLs will be accessed. The cache implementation needs explicit existence checks and efficient deletion for eviction.
flowchart LR
A("Incoming request") --> B{"Cache.has(url)?"}
B -->|Yes| C("Return cached response")
B -->|No| D("Fetch from server")
D --> E("Store in cache")
E --> F{"Cache size > limit?"}
F -->|Yes| G("Evict oldest entry")
F -->|No| H("Return response")
G --> H
style C stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style G stroke:#7c9cf0,fill:#142544,color:#eaf2ff
style B stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
interface CacheEntry<T> {
value: T;
timestamp: number;
hits: number;
}
class LRUCache<T> {
private cache = new Map<string, CacheEntry<T>>();
private maxSize: number;
private maxAge: number;
constructor(maxSize: number, maxAge: number) {
this.maxSize = maxSize;
this.maxAge = maxAge;
}
get(key: string): T | undefined {
const entry = this.cache.get(key);
if (!entry) return undefined;
const age = Date.now() - entry.timestamp;
if (age > this.maxAge) {
this.cache.delete(key);
return undefined;
}
entry.hits++;
this.cache.delete(key);
this.cache.set(key, entry);
return entry.value;
}
set(key: string, value: T): void {
if (this.cache.size >= this.maxSize) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
this.cache.set(key, {
value,
timestamp: Date.now(),
hits: 0,
});
}
clear(): void {
this.cache.clear();
}
}The cache implementation relies on Map features that Record cannot provide. Moving the entry to the end of insertion order after each access maintains LRU semantics. Checking size with .size instead of Object.keys().length keeps the hot path fast. Deleting the oldest entry with a single .delete() call avoids deoptimizing the structure.
Event emitter implementations demonstrate another case where Map prevents bugs. An event system stores listener arrays by event name, but event names come from application code at runtime. Using Record<string, Function[]> assumes every event name has a listener array, leading to undefined errors when emitting unregistered events.
class EventEmitter {
private listeners = new Map<string, Set<Function>>();
on(event: string, listener: Function): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(listener);
return () => this.off(event, listener);
}
off(event: string, listener: Function): void {
const eventListeners = this.listeners.get(event);
if (eventListeners) {
eventListeners.delete(listener);
if (eventListeners.size === 0) {
this.listeners.delete(event);
}
}
}
emit(event: string, ...args: unknown[]): void {
const eventListeners = this.listeners.get(event);
if (eventListeners) {
eventListeners.forEach((listener) => listener(...args));
}
}
}The explicit .has() check prevents the common error of iterating a undefined array. The .get() method returns undefined when no listeners exist, making absence part of the API contract rather than a runtime surprise.
Migration Patterns: Converting Between Record and Map
Converting between Record and Map requires understanding what guarantees each structure provides and what the conversion loses. Moving from Record to Map sacrifices compile-time key validation but gains runtime safety. Moving from Map to Record sacrifices runtime guarantees but enables JSON serialization and object spread.
Converting Record to Map preserves data but loses type information about specific keys. The type system cannot validate that a particular key exists in the Map because the key set becomes unbounded. This conversion makes sense when adding dynamic keys to a previously static structure.
type ConfigKey = 'apiUrl' | 'timeout' | 'retries';
const staticConfig: Record<ConfigKey, string | number> = {
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3,
};
// Convert to Map for runtime additions
const dynamicConfig = new Map(Object.entries(staticConfig));
// Add runtime keys that would not compile with Record
dynamicConfig.set('customHeader', 'Bearer token');
dynamicConfig.set('debugMode', true);Converting Map to Record requires runtime validation that the resulting object matches the expected type. The type system cannot verify that every required key exists in the Map, so developers must check explicitly or accept a partial type.
function mapToRecord<K extends string, V>(
map: Map<K, V>,
requiredKeys: K[]
): Record<K, V> | null {
const record = Object.fromEntries(map) as Record<K, V>;
for (const key of requiredKeys) {
if (!(key in record)) {
return null;
}
}
return record;
}
const userMap = new Map([
['id', '123'],
['name', 'Alice'],
['email', 'alice@example.com'],
]);
type User = { id: string; name: string; email: string };
const userRecord = mapToRecord(userMap, ['id', 'name', 'email']) as Record<keyof User, string>;The conversion highlights the fundamental difference between structures. Record assumes keys exist and provides compile-time validation. Map makes existence a runtime question and provides methods to answer it. Converting between them moves the validation boundary but cannot eliminate the need for validation.
A hybrid approach uses Record for core schema and Map for extensions. An API response type defines required fields as Record and optional metadata as Map. This pattern preserves type safety for known fields while supporting arbitrary metadata.
interface APIResponse<T> {
data: T;
metadata: Map<string, unknown>;
headers: Record<string, string>;
}
function createResponse<T>(data: T): APIResponse<T> {
return {
data,
metadata: new Map(),
headers: {
'content-type': 'application/json',
'cache-control': 'no-cache',
},
};
}The headers field uses Record because HTTP headers have known names and string values. The metadata field uses Map because applications attach arbitrary debugging information or tracing data that cannot be typed statically.
Frequently Asked Questions
When should developers prefer Record over Map in TypeScript?
Developers should prefer Record when the key set is known at compile time and rarely changes. Configuration objects, API response types, and lookup tables with fixed keys benefit from compile-time validation and JSON serialization support that Record provides.
Does Map provide better performance than Record for large datasets?
Map provides better iteration and deletion performance regardless of size, and maintains consistent lookup performance for frequently mutated collections. For static collections under 100 entries, Record uses less memory, but the difference becomes negligible at scale.
How do developers safely access Record values when keys might not exist?
Developers must use optional chaining (record?.[key]) or explicit undefined checks (if (key in record)) when accessing Record values with dynamic keys. The type system cannot enforce existence, so runtime checks are required. For genuinely dynamic keys, Map provides safer defaults through explicit .has() and .get() methods.
Can Map keys be non-string types in TypeScript?
Yes, Map accepts objects, symbols, numbers, and any other type as keys. This enables patterns like caching by DOM element or indexing by class constructor that Record cannot support. The key comparison uses reference equality for objects rather than string serialization.
What happens to type safety when converting between Record and Map?
Converting Record to Map loses compile-time validation of specific keys but preserves runtime data. Converting Map to Record requires runtime validation to ensure required keys exist because the type system cannot verify Map contents. Both conversions require explicit handling of the safety-performance tradeoff.
Choosing the Right Structure for Your TypeScript Project
The choice between Record<K, V> and Map<K, V> determines whether bugs surface at compile time or runtime. Teams building configuration systems, API clients, and type-safe state machines benefit from Record because the compiler catches errors before deployment. Teams building caches, event systems, and dynamic indexes benefit from Map because runtime guarantees prevent the silent failures that types cannot see.
Start with Record when the key set is known and the structure rarely changes. The compile-time validation catches typos, enforces exhaustiveness, and enables refactoring tools to update every access site. Move to Map when keys become unpredictable or the structure mutates frequently. The explicit existence checks and efficient mutations prevent the crashes that plague Record with dynamic data.
That covers the essential patterns for choosing between Record and Map in TypeScript. Apply these principles in production and the difference will be immediate. Related patterns appear in TypeScript generic constraints with extends and keyof for constraining key types and TypeScript mapped types guide for transforming Record structures.