Understanding TypeScript's Map Type
You're building a cache for your API responses, but using a plain object means you can't use anything but strings as keys. Or maybe you're trying to track the order users were added to your application, but object property order isn't guaranteed. Enter TypeScript's Map – your solution for powerful key-value storage that goes beyond object limitations.
Maps give you non-string keys, guaranteed insertion order, and better performance for frequent additions and deletions. Whether you're implementing a real-time leaderboard, managing API request states, or building a cache with TTL, Maps often outperform plain objects.
This guide covers practical Map usage patterns, performance considerations, and TypeScript-specific features that make Maps a compelling choice for modern applications.
Map vs. Object: When to Use Which
The choice between Map and plain objects isn't always obvious. Here's when each shines:
| Scenario | Use Map When | Use Object When |
|---|---|---|
| Keys | You need non-string keys (numbers, objects, symbols) | You only need string/symbol keys |
| Size | You frequently add/remove entries | You have a fixed set of known properties |
| Iteration | You need guaranteed insertion order | Order doesn't matter or you're using modern JS |
| Performance | Frequent additions/deletions (>1000 operations/sec) | Mostly read operations with occasional updates |
| Memory | You're storing many key-value pairs (>100) | You have a small, fixed schema |
| Serialization | You can handle custom serialization | You need direct JSON support |
Real-World Decision Examples
// Use Map: Dynamic cache with object keys
const apiCache = new Map<Request, Promise<Response>>();
// Use Map: User sessions with guaranteed iteration order
const activeSessions = new Map<string, { userId: string; lastActivity: Date }>();
// Use Object: Configuration with known properties
const appConfig = {
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3
};
// Use Object: Component props with TypeScript interfaces
interface UserProps {
name: string;
email: string;
role: 'admin' | 'user';
}
So why reach for a Map over a plain object? Maps excel when you need the flexibility of any key type, guaranteed iteration order, or frequent modifications. Objects work better for structured data with known properties.
Building a Cache with TTL Using TypeScript Maps
Let's build something practical: an API response cache that automatically expires old data. This showcases Maps' strengths for real-world applications:
interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number; // Time to live in milliseconds
}
class ApiCache<T> {
private cache = new Map<string, CacheEntry<T>>();
private defaultTtl: number;
constructor(defaultTtl: number = 300000) { // 5 minutes default
this.defaultTtl = defaultTtl;
}
set(key: string, data: T, customTtl?: number): void {
this.cache.set(key, {
data,
timestamp: Date.now(),
ttl: customTtl ?? this.defaultTtl
});
}
get(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) return null;
// Check if expired
if (Date.now() - entry.timestamp > entry.ttl) {
this.cache.delete(key);
return null;
}
return entry.data;
}
// Clean up expired entries
cleanup(): number {
const now = Date.now();
let deletedCount = 0;
for (const [key, entry] of this.cache) {
if (now - entry.timestamp > entry.ttl) {
this.cache.delete(key);
deletedCount++;
}
}
return deletedCount;
}
}
// Usage example
const apiCache = new ApiCache<any>();
async function fetchUserProfile(userId: string) {
const cacheKey = `user:${userId}`;
// Try cache first
const cached = apiCache.get(cacheKey);
if (cached) return cached;
// Fetch from API
const response = await fetch(`/api/users/${userId}`);
const userData = await response.json();
// Cache with 10-minute TTL
apiCache.set(cacheKey, userData, 600000);
return userData;
}
This cache implementation leverages Maps' guaranteed iteration order for cleanup and efficient key-based lookups. Unlike objects, Maps handle any key type and won't accidentally conflict with prototype properties.
Tracking API Request States
Here's another practical example - tracking the loading state of multiple API requests:
type RequestState = 'idle' | 'loading' | 'success' | 'error';
interface ApiRequestTracker {
state: RequestState;
data?: any;
error?: string;
timestamp: number;
}
const requestTracker = new Map<string, ApiRequestTracker>();
function trackApiCall(endpoint: string, initialState: RequestState = 'loading') {
requestTracker.set(endpoint, {
state: initialState,
timestamp: Date.now()
});
}
function updateRequestState(endpoint: string, state: RequestState, data?: any, error?: string) {
const current = requestTracker.get(endpoint);
if (current) {
requestTracker.set(endpoint, {
...current,
state,
data,
error,
timestamp: Date.now()
});
}
}
// Usage in an async function
async function loadDashboardData() {
const endpoints = ['users', 'posts', 'analytics'];
// Track all requests
endpoints.forEach(endpoint => trackApiCall(`/api/${endpoint}`));
// Load data concurrently
const promises = endpoints.map(async endpoint => {
try {
const response = await fetch(`/api/${endpoint}`);
const data = await response.json();
updateRequestState(`/api/${endpoint}`, 'success', data);
return data;
} catch (error) {
updateRequestState(`/api/${endpoint}`, 'error', undefined, error.message);
throw error;
}
});
return Promise.allSettled(promises);
}
Converting TypeScript Objects to Maps
When working with existing data structures, you'll often need to convert objects to Maps:
// Converting API configuration to a Map for dynamic access
const apiEndpoints = {
users: '/api/users',
posts: '/api/posts',
comments: '/api/comments'
};
const endpointMap = new Map(Object.entries(apiEndpoints));
// Now you can dynamically construct API calls
function buildApiUrl(endpoint: string, id?: string): string {
const basePath = endpointMap.get(endpoint);
if (!basePath) throw new Error(`Unknown endpoint: ${endpoint}`);
return id ? `${basePath}/${id}` : basePath;
}
// Converting user preferences with type safety
interface UserPreferences {
theme: 'light' | 'dark';
notifications: boolean;
language: string;
}
const defaultPrefs: UserPreferences = {
theme: 'light',
notifications: true,
language: 'en'
};
// Convert to Map for dynamic preference lookup
const prefsMap = new Map<keyof UserPreferences, UserPreferences[keyof UserPreferences]>(
Object.entries(defaultPrefs) as [keyof UserPreferences, UserPreferences[keyof UserPreferences]][]
);
function getUserPreference<K extends keyof UserPreferences>(key: K): UserPreferences[K] {
return prefsMap.get(key) as UserPreferences[K];
}
The generics system ensures type safety when converting between objects and Maps, preventing runtime errors from mismatched data types.
Iterating Over TypeScript Maps
Maps guarantee insertion order, which means your loops are predictable. Here's how to iterate through them:
Using for...of with Destructuring
This is your go-to approach when you need both keys and values:
for (const [key, value] of userMap) {
console.log(`Key: ${key}, Name: ${value.name}, Age: ${value.age}`);
}
Iterating Through Keys or Values Only
Sometimes you only need the keys or values:
// Iterate through keys
for (const key of userMap.keys()) {
console.log(`User ID: ${key}`);
}
// Iterate through values
for (const value of userMap.values()) {
console.log(`Name: ${value.name}, Age: ${value.age}`);
}
Using forEach Method
The Map class provides a forEach method similar to arrays:
userMap.forEach((value, key) => {
console.log(`User ${key}: ${value.name} is ${value.age} years old`);
});
Note that Map's forEach is backwards from arrays - value comes first, then key.
forEach loops give you clean, readable iteration syntax when you prefer method chaining over for...of loops.
Checking if a Map Contains a Specific Key
Need to check if a key exists? Use the has() method:
// Check if a key exists in the Map
const hasUser = userMap.has('1');
console.log(hasUser); // Output: true
// Use in conditional statements
if (userMap.has('3')) {
console.log('User 3 exists');
} else {
console.log('User 3 not found'); // This will execute
}
The has() method returns true or false - no undefined surprises like you get with object property access.
Best Practices for Key Checking
When working with Map keys in TypeScript projects, consider these approaches:
// Get a value safely with fallback
const userName = userMap.get('3')?.name ?? 'Unknown User';
// Combine has() with get() for more control
function getUser(id: string) {
if (!userMap.has(id)) {
throw new Error(`User with ID ${id} not found`);
}
return userMap.get(id)!; // Safe to use non-null assertion here
}
Using typeof checks with Map operations can provide additional type safety when handling complex data structures.
Converting TypeScript Maps to Arrays
Converting Maps to arrays is useful for serialization, data manipulation, or when working with functions that expect array inputs. TypeScript's Map interface provides several methods to facilitate these conversions.
Converting to an Array of Key-Value Pairs
To transform a Map into an array of key-value pair entries:
const userMap = new Map<string, { name: string; age: number }>([
['1', { name: 'John Doe', age: 30 }],
['2', { name: 'Jane Doe', age: 25 }]
]);
// Convert to array of entries
const entries = Array.from(userMap);
// Result: [['1', {name: 'John Doe', age: 30}], ['2', {name: 'Jane Doe', age: 25}]]
// Alternative using the entries() method
const entriesAlt = Array.from(userMap.entries());
// Identical result
Converting to Separate Key and Value Arrays
Sometimes you need separate arrays for keys and values:
// Get an array of all keys
const keys = Array.from(userMap.keys());
// Result: ['1', '2']
// Get an array of all values
const values = Array.from(userMap.values());
// Result: [{name: 'John Doe', age: 30}, {name: 'Jane Doe', age: 25}]
Creating Custom Arrays from Maps
You can use Array mapping functions to transform Map data:
// Create a custom array from Map entries
const userNames = Array.from(userMap, ([id, user]) => `${id}: ${user.name}`);
// Result: ['1: John Doe', '2: Jane Doe']
When manipulating large datasets in TypeScript applications, array methods combined with Map operations provide powerful data transformation capabilities.
Memory Management and Performance Considerations
Understanding when to use Map vs. WeakMap and the performance implications can save you from memory leaks and performance bottlenecks in production applications.
Map vs. WeakMap: Preventing Memory Leaks
Regular Maps hold strong references to their keys, which can prevent garbage collection. WeakMaps only hold weak references, allowing automatic cleanup:
// Regular Map - can cause memory leaks
const componentCache = new Map<HTMLElement, ComponentData>();
// DOM element gets removed but stays in memory due to Map reference
function attachComponent(element: HTMLElement, data: ComponentData) {
componentCache.set(element, data);
}
// WeakMap - automatically cleans up when DOM element is removed
const componentCacheWeak = new WeakMap<HTMLElement, ComponentData>();
function attachComponentSafe(element: HTMLElement, data: ComponentData) {
componentCacheWeak.set(element, data);
// When element is removed from DOM, this entry automatically disappears
}
// WeakMap example for tracking user sessions without memory leaks
class SessionManager {
private userSessions = new WeakMap<User, SessionData>();
startSession(user: User, sessionData: SessionData) {
this.userSessions.set(user, sessionData);
// When user object is no longer referenced, session data is cleaned up
}
getSession(user: User): SessionData | undefined {
return this.userSessions.get(user);
}
}
Use WeakMap when:
- Keys are objects that might be removed from memory
- You're building caches tied to DOM elements or temporary objects
- You want automatic cleanup without manual memory management
Use Map when:
- You need to iterate over entries (WeakMap isn't iterable)
- Keys are primitives (strings, numbers, symbols)
- You need to know the size or clear all entries
Performance Characteristics
Maps and objects have different performance profiles depending on your use case:
// Performance test: Map vs Object for frequent updates
interface PerformanceTest {
addItem: (key: string, value: any) => void;
getItem: (key: string) => any;
deleteItem: (key: string) => boolean;
size: () => number;
}
// Map implementation - better for frequent add/delete
class MapStore implements PerformanceTest {
private store = new Map<string, any>();
addItem(key: string, value: any) { this.store.set(key, value); }
getItem(key: string) { return this.store.get(key); }
deleteItem(key: string) { return this.store.delete(key); }
size() { return this.store.size; }
}
// Object implementation - better for mostly-read operations
class ObjectStore implements PerformanceTest {
private store: Record<string, any> = {};
private _size = 0;
addItem(key: string, value: any) {
if (!(key in this.store)) this._size++;
this.store[key] = value;
}
getItem(key: string) { return this.store[key]; }
deleteItem(key: string) {
if (key in this.store) {
delete this.store[key];
this._size--;
return true;
}
return false;
}
size() { return this._size; }
}
Performance Guidelines:
- Maps excel at frequent additions/deletions (>1000 operations per second)
- Objects excel for mostly-read scenarios with known property sets
- Maps maintain consistent O(1) performance regardless of size
- Objects can degrade to O(n) for property access in some JavaScript engines with many properties
When Maps Become Inefficient
Even Maps have limitations. Here's when to consider alternatives:
// Large Maps with complex objects can consume significant memory
const hugeDataMap = new Map<string, LargeObject>();
// Alternative: Store references or use pagination
interface DataReference {
id: string;
lastAccessed: number;
}
class PaginatedDataStore {
private refs = new Map<string, DataReference>();
private cache = new Map<string, LargeObject>();
private maxCacheSize = 1000;
async getData(id: string): Promise<LargeObject> {
// Check cache first
const cached = this.cache.get(id);
if (cached) {
this.refs.set(id, { id, lastAccessed: Date.now() });
return cached;
}
// Fetch and cache with LRU eviction
const data = await this.fetchFromStorage(id);
if (this.cache.size >= this.maxCacheSize) {
this.evictLeastRecentlyUsed();
}
this.cache.set(id, data);
this.refs.set(id, { id, lastAccessed: Date.now() });
return data;
}
private evictLeastRecentlyUsed() {
let oldestId = '';
let oldestTime = Date.now();
for (const [id, ref] of this.refs) {
if (ref.lastAccessed < oldestTime) {
oldestTime = ref.lastAccessed;
oldestId = id;
}
}
this.cache.delete(oldestId);
this.refs.delete(oldestId);
}
private async fetchFromStorage(id: string): Promise<LargeObject> {
// Simulate database/API call
return {} as LargeObject;
}
}
Deleting Entries from TypeScript Maps
The TypeScript Map interface provides straightforward methods for removing specific entries when you no longer need them:
const userMap = new Map<string, { name: string; age: number }>([
['1', { name: 'John Doe', age: 30 }],
['2', { name: 'Jane Doe', age: 25 }],
['3', { name: 'Bob Smith', age: 40 }]
]);
// Delete a specific entry by key
const wasDeleted = userMap.delete('2');
console.log(wasDeleted); // Output: true
console.log(userMap.size); // Output: 2
// Attempting to delete a non-existent key
const nonExistentDelete = userMap.delete('5');
console.log(nonExistentDelete); // Output: false
The delete() method returns a boolean indicating whether the operation was successful, which is useful for conditional operations:
function removeUserIfExists(id: string): boolean {
if (userMap.has(id)) {
return userMap.delete(id);
}
return false;
}
This pattern is useful when implementing features like user account management or cache invalidation in TypeScript applications.
Clearing All Entries from TypeScript Maps
When you need to reset a Map completely, TypeScript's Map interface provides the clear() method:
const userMap = new Map<string, { name: string; age: number }>([
['1', { name: 'John Doe', age: 30 }],
['2', { name: 'Jane Doe', age: 25 }],
['3', { name: 'Bob Smith', age: 40 }]
]);
console.log(userMap.size); // Output: 3
// Clear all entries
userMap.clear();
console.log(userMap.size); // Output: 0
The clear() method efficiently removes all entries without returning any value. This operation is much faster than deleting entries one by one, making it ideal for managing data lifecycle in utility types like caches or temporary stores.
When to Clear Maps in TypeScript Applications
Clearing Maps is particularly useful in these scenarios:
- Implementing reset functionality in user interfaces
- Managing memory in long-running applications
- Refreshing cached data when it becomes stale
- Preparing a Map for reuse with a new dataset
Knowing when to maintain state and when to clear it can significantly impact performance and user experience.
Ensuring Type Safety with Maps
TypeScript's static type system shines when working with Maps, providing compile-time safety for your key-value operations. Let's explore how to maintain type safety effectively:
// Define a type-safe Map
type UserMap = Map<string, { name: string; age: number }>;
// Create an instance with the defined type
const userMap: UserMap = new Map();
// Type-safe access to values
function getUser(id: string): { name: string; age: number } | undefined {
const user = userMap.get(id);
return user; // TypeScript knows this is either a user object or undefined
}
This approach enforces consistent data structures throughout your application. The Record<K, T> offers similar type safety for object literals, but Maps provide additional functionality and flexibility.
Using Type Guards with Maps
To handle the potential undefined values when retrieving from a Map:
function getUserName(id: string): string {
const user = userMap.get(id);
// Type guard ensures we only use defined values
if (!user) {
return 'Unknown User';
}
return user.name;
}
Combining TypeScript's type safety with proper validation ensures your data remains consistent across your entire stack. The pattern above helps prevent runtime errors while maintaining clean, readable code.
Defining a TypeScript Map Type with Specific Key and Value Types
Creating a dedicated type for your Maps adds clarity to your codebase and improves IntelliSense support:
// Define a reusable Map type for users
type UserMap = Map<string, { name: string; age: number }>;
// Create multiple instances with consistent typing
const activeUsers: UserMap = new Map();
const archivedUsers: UserMap = new Map();
This pattern establishes a contract for your Maps, ensuring consistent usage throughout your application. For complex value types, you can leverage interface definitions to enhance readability:
interface User {
name: string;
age: number;
roles: string[];
lastLogin?: Date;
}
type UserDatabase = Map<string, User>;
const users: UserDatabase = new Map();
users.set('user1', {
name: 'John Doe',
age: 30,
roles: ['admin', 'editor']
});
These type definitions create a shared vocabulary between your frontend and backend, ensuring consistency across your full-stack TypeScript application.
Extending Map Types
For specialized use cases, you can extend the standard Map class:
class CacheMap<K, V> extends Map<K, V> {
private maxSize: number;
constructor(maxSize: number) {
super();
this.maxSize = maxSize;
}
set(key: K, value: V): this {
if (this.size >= this.maxSize && !this.has(key)) {
const firstKey = this.keys().next().value;
this.delete(firstKey);
}
return super.set(key, value);
}
}
const cache = new CacheMap<string, string>(3);
This example shows how you can customize Map behavior while maintaining TypeScript's type safety guarantees. For projects requiring complex data management, tools like convex-helpers provide ready-made utilities for custom functionality while preserving type information.
Handling Optional Keys and Values
When working with Maps in TypeScript, you'll often need to gracefully handle missing or optional values. TypeScript's optional chaining and nullish coalescing operators make this straightforward:
type UserMap = Map<string, { name: string; age: number; email?: string }>;
const userMap: UserMap = new Map([
['1', { name: 'John Doe', age: 30, email: 'john@example.com' }],
['2', { name: 'Jane Doe', age: 25 }]
]);
// Handle potentially undefined values
function getUserEmail(id: string): string {
// Optional chaining for possibly undefined map entries
const email = userMap.get(id)?.email;
// Nullish coalescing for fallback values
return email ?? 'No email provided';
}
console.log(getUserEmail('1')); // Output: john@example.com
console.log(getUserEmail('2')); // Output: No email provided
console.log(getUserEmail('3')); // Output: No email provided
The optional chaining operator (?.) safely accesses nested properties without throwing errors when intermediate values are undefined. This pattern is particularly useful when working with data from APIs or user input.
For applications built with Convex, this approach aligns well with our validation patterns, ensuring your frontend safely consumes backend data even when certain fields are optional.
Typed Default Values
You can also implement type-safe default values for Map entries:
function getUser(id: string, defaultUser?: { name: string; age: number }): { name: string; age: number } {
return userMap.get(id) ?? defaultUser ?? { name: 'Guest', age: 0 };
}
// Provide custom defaults
const user = getUser('999', { name: 'Anonymous', age: 18 });
This pattern gives you fine-grained control over how missing values are handled, improving the robustness of your TypeScript applications. When building complex data models, these techniques help maintain end-to-end type safety from your database to your UI.
Performing Type Checks on Elements
When working with Maps containing complex value types, it's often necessary to verify the shape or properties of retrieved elements:
type UserMap = Map<string, { name: string; age: number } | { name: string; company: string }>;
const userMap: UserMap = new Map();
userMap.set('1', { name: 'John Doe', age: 30 });
userMap.set('2', { name: 'Jane Doe', company: 'Acme Inc.' });
// Type guard function
function isEmployee(user: any): user is { name: string; company: string } {
return typeof user === 'object' && user !== null && 'company' in user;
}
function getUserInfo(id: string): string {
const user = userMap.get(id);
if (!user) {
return 'User not found';
}
// Type narrowing with custom type guard
if (isEmployee(user)) {
return `${user.name} works at ${user.company}`;
} else {
return `${user.name} is ${user.age} years old`;
}
}
This approach leverages typeof and type predicates to ensure type-safe access to properties. The in operator is particularly useful for discriminating between different object shapes within union types.
For applications built with Convex, these type checking patterns complement server-side validation, creating a cohesive type safety strategy across your stack.
Using TypeScript's Discriminated Unions
A more structured approach uses discriminated unions with a type field:
type Person = { type: 'person'; name: string; age: number };
type Employee = { type: 'employee'; name: string; company: string };
type MapValue = Person | Employee;
const entityMap = new Map<string, MapValue>();
entityMap.set('1', { type: 'person', name: 'John Doe', age: 30 });
entityMap.set('2', { type: 'employee', name: 'Jane Doe', company: 'Acme Inc.' });
function getEntityInfo(id: string): string {
const entity = entityMap.get(id);
if (!entity) return 'Not found';
// TypeScript can infer the correct type based on the discriminant
switch (entity.type) {
case 'person':
return `${entity.name} is ${entity.age} years old`;
case 'employee':
return `${entity.name} works at ${entity.company}`;
}
}
This pattern, known as discriminated union, provides more robust type safety than manual type checks. When implemented consistently, it ensures that all code paths are properly typed and handles all possible variants.
Advanced TypeScript Features with Maps
TypeScript's type system offers powerful features that work exceptionally well with Maps, enabling sophisticated type-safe patterns.
Conditional Types with Map Lookups
You can use conditional types to create smart Map operations that adapt based on the key type:
// Define a lookup type for different data shapes
type DataRegistry = {
user: { id: string; name: string; email: string };
product: { id: string; title: string; price: number };
order: { id: string; userId: string; products: string[]; total: number };
};
// Conditional type that returns the correct value type based on key
type MapValue<K extends keyof DataRegistry> = DataRegistry[K];
class TypedDataStore {
private maps = {
user: new Map<string, DataRegistry['user']>(),
product: new Map<string, DataRegistry['product']>(),
order: new Map<string, DataRegistry['order']>()
};
// TypeScript infers the correct return type based on the entity type
get<K extends keyof DataRegistry>(
entityType: K,
id: string
): MapValue<K> | undefined {
return this.maps[entityType].get(id) as MapValue<K> | undefined;
}
set<K extends keyof DataRegistry>(
entityType: K,
id: string,
data: MapValue<K>
): void {
this.maps[entityType].set(id, data as any);
}
}
// Usage with full type safety
const store = new TypedDataStore();
store.set('user', '1', { id: '1', name: 'John', email: 'john@example.com' });
// TypeScript knows this returns DataRegistry['user'] | undefined
const user = store.get('user', '1');
if (user) {
console.log(user.email); // ✅ TypeScript knows .email exists
}
// TypeScript prevents invalid operations
// store.set('user', '1', { id: '1', title: 'Product' }); // ❌ Type error
Map with Template Literal Types
Combine Maps with template literal types for powerful key validation:
// Define valid API endpoints using template literals
type ApiEndpoint = `api/${string}`;
type CacheKey = `cache:${string}:${number}`;
class ApiCacheManager {
private cache = new Map<CacheKey, any>();
private endpoints = new Map<ApiEndpoint, string>();
// Only accept valid cache key format
cacheResponse<T>(key: CacheKey, data: T): void {
this.cache.set(key, data);
}
// Only accept valid API endpoint format
registerEndpoint(endpoint: ApiEndpoint, handler: string): void {
this.endpoints.set(endpoint, handler);
}
}
const cacheManager = new ApiCacheManager();
// ✅ Valid usage
cacheManager.cacheResponse('cache:users:123', userData);
cacheManager.registerEndpoint('api/users', 'UserHandler');
// ❌ TypeScript catches invalid formats
// cacheManager.cacheResponse('invalid-key', userData); // Type error
// cacheManager.registerEndpoint('invalid-endpoint', 'Handler'); // Type error
Integration with Convex Type-Safe Schemas
When building full-stack TypeScript applications with Convex, Maps integrate seamlessly with type-safe database schemas:
// Convex schema definition (simplified example)
interface ConvexUser {
_id: string;
name: string;
email: string;
preferences: {
theme: 'light' | 'dark';
notifications: boolean;
};
}
// Client-side Map that mirrors server schema
class ConvexUserCache {
private userCache = new Map<string, ConvexUser>();
private subscriptions = new Map<string, () => void>();
// Type-safe caching that matches Convex schema
cacheUser(user: ConvexUser): void {
this.userCache.set(user._id, user);
}
// Subscribe to real-time updates with type safety
subscribeToUser(userId: string, callback: (user: ConvexUser) => void): () => void {
const unsubscribe = () => {
this.subscriptions.delete(userId);
};
this.subscriptions.set(userId, unsubscribe);
// Emit cached data immediately if available
const cached = this.userCache.get(userId);
if (cached) {
callback(cached);
}
return unsubscribe;
}
// Bulk operations with type inference
syncFromConvex(users: ConvexUser[]): void {
for (const user of users) {
this.cacheUser(user);
}
}
}
// Usage with full end-to-end type safety
const userCache = new ConvexUserCache();
// TypeScript ensures data matches Convex schema
userCache.syncFromConvex([
{
_id: '1',
name: 'John Doe',
email: 'john@example.com',
preferences: { theme: 'dark', notifications: true }
}
]);
These TypeScript-specific patterns with Maps ensure your data structures are not only performant but also maintain strict type safety from your database to your UI components.
Common Challenges and Solutions
When working with TypeScript's Map type, you may encounter several common challenges. Here are practical solutions to address them:
TypeScript Map Serialization and Persistence Patterns
TypeScript Maps can't be directly serialized to JSON, but there are several strategies to persist and transmit Map data effectively:
Storing TypeScript Maps in localStorage
For client-side persistence, you'll need custom serialization logic:
interface MapStorage<K, V> {
save(key: string, map: Map<K, V>): void;
load(key: string): Map<K, V> | null;
remove(key: string): void;
}
class LocalStorageMapManager<K extends string | number, V> implements MapStorage<K, V> {
save(key: string, map: Map<K, V>): void {
try {
const serialized = JSON.stringify(Array.from(map.entries()));
localStorage.setItem(key, serialized);
} catch (error) {
console.error('Failed to save TypeScript Map to localStorage:', error);
}
}
load(key: string): Map<K, V> | null {
try {
const stored = localStorage.getItem(key);
if (!stored) return null;
const entries = JSON.parse(stored);
return new Map(entries);
} catch (error) {
console.error('Failed to load TypeScript Map from localStorage:', error);
return null;
}
}
remove(key: string): void {
localStorage.removeItem(key);
}
}
// Usage with application state
const userPrefsManager = new LocalStorageMapManager<string, any>();
const userPreferences = new Map([
['theme', 'dark'],
['language', 'en'],
['notifications', true]
]);
// Save preferences
userPrefsManager.save('user-prefs', userPreferences);
// Load on app startup
const loadedPrefs = userPrefsManager.load('user-prefs') || new Map();
Sending TypeScript Maps Over APIs
When working with REST APIs or GraphQL, convert Maps to objects for transmission:
interface ApiMapPayload<T> {
entries: [string, T][];
metadata?: {
size: number;
timestamp: number;
};
}
class ApiMapClient<T> {
async sendMapData(endpoint: string, map: Map<string, T>): Promise<Response> {
const payload: ApiMapPayload<T> = {
entries: Array.from(map.entries()),
metadata: {
size: map.size,
timestamp: Date.now()
}
};
return fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}
async receiveMapData(endpoint: string): Promise<Map<string, T>> {
const response = await fetch(endpoint);
const payload: ApiMapPayload<T> = await response.json();
return new Map(payload.entries);
}
}
For back-end systems built with Convex, you might consider storing normalized data and reconstructing TypeScript Maps client-side.
2. Type-Safe Map Initialization from Variables
Initializing Maps with dynamic data while maintaining type safety can be tricky:
function createUserMap(userData: [string, { name: string; age: number }][]): Map<string, { name: string; age: number }> {
return new Map(userData);
}
// Type inference works with const assertions
const initialData = [
['1', { name: 'John Doe', age: 30 }],
['2', { name: 'Jane Doe', age: 25 }]
] as const;
Using as const with literal data preserves literal types, improving type inference when working with Maps.
3. Maintaining Key Order
Unlike objects, Maps preserve insertion order, which can be crucial for certain applications:
const orderedMap = new Map<number, string>();
// Insertion order is preserved regardless of key values
orderedMap.set(5, 'five');
orderedMap.set(1, 'one');
orderedMap.set(3, 'three');
This predictable iteration order makes Maps ideal for implementing ordered caches, recent items lists, or any feature requiring stable iteration order.
Advanced Usage
Maps are useful for advanced tasks like storing metadata, caching, or memoization. They also work with non-string keys, such as objects or functions.
Using Non-String Keys
Maps allow keys of any data type, offering more flexibility than objects.
const objKey = { id: 1 };
const map = new Map<object, string>([[objKey, 'objectKey']]);
console.log(map.get(objKey)); // Output: objectKey
This capability is invaluable when implementing data structures like caches or when Convex custom filters require keying by complex objects.
Comparing Map Entries
Unlike objects, Maps let you directly compare keys and values of any type.
const map1 = new Map<string, number>([['apple', 1]]);
const map2 = new Map<string, number>([['apple', 1]]);
console.log(map1.get('apple') === map2.get('apple')); // Output: true
This simplifies equality checks in array operations and filtering logic.
Converting Maps to Other Data Structures
Maps can be turned into arrays or objects using Array.from() or spreading into a new object.
const map = new Map<string, number>([['apple', 1], ['banana', 2]]);
// To array of entries
const array = Array.from(map);
// To plain object
const obj = Object.fromEntries(map);
These conversions are especially useful when integrating with APIs or when working with Convex functions that expect different data formats.
Final Thoughts on TypeScript Maps
TypeScript Maps give you non-string keys, guaranteed iteration order, and better performance for frequent additions and deletions. They're your best choice when you need flexible key-value storage that goes beyond object limitations.
Use the patterns from this guide to build applications that are both type-safe and performant. Whether you're implementing a TTL cache, tracking API states, or building complex data structures, Maps provide the reliability and flexibility modern TypeScript applications demand.