TypeScript Dictionaries: A Practical Guide
Unlike Python or C#, TypeScript doesn't ship with a built-in Dictionary type. That gap trips developers up constantly. You know you want key-value storage, but you're staring at three different options (Map, index signatures, and Record) with no clear signal about which one to reach for. Pick wrong and you'll either fight TypeScript's type checker or end up with code that's harder to refactor than it needs to be.
This guide cuts through that. You'll see how each approach works, when to use which, and how to handle the everyday operations (iterating, merging, checking keys, removing entries) without second-guessing yourself.
Creating a TypeScript Dictionary
An object type in TypeScript can be created using either the built-in Map<K, V> object or a plain object with an index signature.
Here's the Map approach:
const productCache = new Map<string, number>();
productCache.set('laptop', 1299);
productCache.set('keyboard', 79);
And here's the plain object approach using an index signature:
const productPrices: { [productId: string]: number } = {};
productPrices['laptop'] = 1299;
Both work, but they're not interchangeable. The right choice depends on your use case (more on that in the comparison section below).
Defining a TypeScript Dictionary Type
Defining a dictionary type up front ensures type safety and keeps your code predictable. Use an interface with an index signature or the Record<K, V> utility type.
With an interface:
interface UserPermissions {
[permission: string]: boolean;
}
const adminPermissions: UserPermissions = {
canEdit: true,
canDelete: true,
canPublish: false,
};
Or with Record<K, V>, which is more concise and often easier to read:
type UserPermissions = Record<string, boolean>;
const editorPermissions: UserPermissions = {
canEdit: true,
canDelete: false,
};
Both give you the same type safety. Record is generally preferred for simple cases since it's less boilerplate.
Creating a Generic Dictionary Type
If you find yourself defining similar dictionary types repeatedly, a generic Dictionary<K, V> type saves you from the repetition:
type Dictionary<K extends string | number | symbol, V> = {
[key in K]: V;
};
// Reuse it for any key-value combination
type ProductPriceMap = Dictionary<string, number>;
type FeatureFlags = Dictionary<string, boolean>;
const prices: ProductPriceMap = {
laptop: 1299,
keyboard: 79,
};
For cases where not every key needs a value, combine it with Partial:
type PartialDictionary<K extends string | number | symbol, V> = Partial<Dictionary<K, V>>;
// Keys become optional
type OptionalFeatureFlags = PartialDictionary<string, boolean>;
const flags: OptionalFeatureFlags = { darkMode: true }; // Missing keys are fine
Use this when you're writing utility functions that need to work with any key-value shape.
Which TypeScript Dictionary Type Should You Use?
The honest answer is: it depends on your scenario. Here's a decision guide based on what you're actually building, not just feature checklists.
| Scenario | Best choice | Why |
|---|---|---|
Config object with known keys ('theme' | 'lang') | Record<K, V> | Exhaustiveness checking — TypeScript errors if you miss a key |
| Lookup from a JSON API response | Index signature or Record<string, V> | Serializes cleanly; no conversion needed |
| Cache keyed by object references (DOM nodes, class instances) | Map<K, V> | Only Map supports non-primitive keys |
| Frequently adding and deleting entries at runtime | Map<K, V> | delete on plain objects is slow; Map.delete() is optimized |
| Need to know the entry count without iterating | Map<K, V> | .size is O(1); Object.keys(obj).length is O(n) |
Needs to round-trip through JSON.stringify | Index signature or Record | Map doesn't serialize automatically |
| Extending or implementing the type with an interface | Index signature | Record and Map don't work as interface implementations |
| Reusable generic utility that works with any key-value shape | Generic Dictionary<K, V> type | See the "Creating a Generic Dictionary Type" section below |
The pattern you'll hit most often: reach for Record when keys are known at compile time, Map when the dataset grows at runtime or keys aren't strings, and an index signature when you're working with dynamic JSON.
Iterating Over a TypeScript Dictionary
How you iterate depends on which dictionary type you chose. Plain objects and Map have different iteration APIs, and mixing them up is a common source of bugs. Use a for loop or Object.keys() for plain objects, and for...of for Map. For more complex filtering operations, check out how to write advanced TypeScript filters.
Here's the for...in approach:
const apiConfig: { [key: string]: string } = {
baseUrl: 'https://api.example.com',
version: 'v2',
timeout: '5000',
};
for (const key in apiConfig) {
if (Object.prototype.hasOwnProperty.call(apiConfig, key)) {
console.log(key, apiConfig[key]);
}
}
Or use Object.keys() with forEach:
Object.keys(apiConfig).forEach(key => {
console.log(key, apiConfig[key]);
});
For Map, use for...of to iterate over entries directly:
const roleMap = new Map<string, string[]>([
['admin', ['read', 'write', 'delete']],
['editor', ['read', 'write']],
]);
for (const [role, permissions] of roleMap) {
console.log(`${role}: ${permissions.join(', ')}`);
}
Checking if a Key Exists in a TypeScript Dictionary
One underappreciated difference between dictionary types: accessing a missing key in a plain object silently returns undefined, while Map.get() makes that possibility explicit in the return type (T | undefined). Both need a key existence check, but Map forces the issue at the type level. Use the in operator for plain objects:
const featureFlags: { [flag: string]: boolean } = {
darkMode: true,
betaFeatures: false,
};
if ('darkMode' in featureFlags) {
console.log('Dark mode flag:', featureFlags.darkMode);
}
Or use the has() method with Map<K, V>:
const sessionCache = new Map<string, string>();
sessionCache.set('userId', '12345');
if (sessionCache.has('userId')) {
console.log('Session active:', sessionCache.get('userId'));
}
has() is generally safer than checking for undefined directly, since a Map can legitimately store undefined as a value.
Updating Values in a TypeScript Dictionary
Unlike some languages where dictionary updates are a distinct operation, TypeScript uses the same method for both inserts and updates. There's no add vs. update distinction. set() and direct assignment handle both. Use the set() method for Map:
const visitCounts = new Map<string, number>();
visitCounts.set('/home', 100);
// Increment the existing count
visitCounts.set('/home', (visitCounts.get('/home') ?? 0) + 1);
Or use direct property assignment with a key value pair for plain objects:
const userProfile: { [field: string]: string } = {
name: 'Alex',
role: 'editor',
};
userProfile.role = 'admin';
Removing Keys from a TypeScript Dictionary
Removal syntax differs between dictionary types, and the performance difference is real in write-heavy scenarios. Map.delete() is significantly faster than the delete operator on plain objects for large collections, worth knowing if you're building a cache with frequent evictions. For Map, use the delete() method:
const activeConnections = new Map<string, number>();
activeConnections.set('conn-001', Date.now());
activeConnections.delete('conn-001');
For plain object dictionaries, use the delete operator:
const sessionData: { [key: string]: string } = {
token: 'abc123',
userId: '456',
};
delete sessionData.token;
Keep in mind that delete on a plain object mutates it in place and can cause performance issues in hot paths. If immutability matters, you can use object destructuring to create a new object without the key instead.
Initializing a TypeScript Dictionary with Default Values
Setting up a dictionary with initial data is common for lookups and configs. With the Map constructor:
const httpStatusMessages = new Map<number, string>([
[200, 'OK'],
[404, 'Not Found'],
[500, 'Internal Server Error'],
]);
Or with object literals using default values:
const defaultPermissions: Record<string, boolean> = {
canRead: true,
canWrite: false,
canDelete: false,
};
Merging TypeScript Dictionaries
Combining dictionaries is a common operation, especially when applying overrides or composing config objects. For plain objects, the spread operator is the cleanest approach:
const defaultConfig: Record<string, string> = {
theme: 'light',
language: 'en',
timezone: 'UTC',
};
const userPreferences: Record<string, string> = {
theme: 'dark', // Override
fontSize: '16px', // New key
};
// Later keys win when there's a conflict
const finalConfig = { ...defaultConfig, ...userPreferences };
// { theme: 'dark', language: 'en', timezone: 'UTC', fontSize: '16px' }
Object.assign() produces the same result but mutates the target instead of creating a new object:
// Mutates defaultConfig in place — usually not what you want
const merged = Object.assign({}, defaultConfig, userPreferences);
Merging two Map instances takes a bit more work since spread doesn't work on them directly:
const defaults = new Map<string, number>([['timeout', 5000], ['retries', 3]]);
const overrides = new Map<string, number>([['timeout', 10000]]);
// Spread both into an array of entries, then build a new Map
const merged = new Map([...defaults, ...overrides]);
// timeout: 10000 (override wins), retries: 3 (kept from defaults)
Common Challenges and Solutions
Problem: Defining a Dictionary with Multiple Value Types
Use union types to handle dictionaries with mixed value types:
type ConfigValue = string | number | boolean;
const appConfig: Record<string, ConfigValue> = {
apiUrl: 'https://api.example.com',
timeout: 5000,
debugMode: false,
};
Problem: Iterating While Maintaining Type Safety
Use Object.entries() with type guards to keep TypeScript happy during iteration:
const settings: Record<string, string | number> = {
theme: 'dark',
fontSize: 16,
};
for (const [key, value] of Object.entries(settings)) {
if (typeof value === 'string') {
console.log(`${key}: ${value.toUpperCase()}`);
} else if (typeof value === 'number') {
console.log(`${key}: ${value.toFixed(2)}`);
}
}
Problem: Removing Keys Without Affecting Type Definitions
Use Omit<T, K> to derive a narrower type when you need to drop a key at the type level:
type UserProfile = { id: string; name: string; password: string };
type PublicProfile = Omit<UserProfile, 'password'>;
const rawUser: UserProfile = { id: '1', name: 'Alex', password: 'secret' };
// Destructure out the sensitive field, TypeScript infers the rest correctly
const { password, ...publicUser } = rawUser;
const safeProfile: PublicProfile = publicUser;
Working with TypeScript Dictionaries
Three patterns, one job. Here's when to reach for each:
-
Use an object type or
Recordfor simple lookups, configs, and anything that needs to round-trip through JSON -
Choose
Mapwhen you need rich methods likehas(),delete(), andsize, or when your keys aren't strings -
Use an interface with an index signature when you need to extend or implement the type elsewhere
-
Use a generic
Dictionary<K, V>type when you're writing reusable utilities that work with any key-value structure -
Prefer the spread operator (
{ ...a, ...b }) for merging plain object dictionaries
Getting comfortable with these patterns means less time debugging "element implicitly has an 'any' type" errors and more time building features. For more details on working with TypeScript in Convex and type safety, check out Convex's TypeScript cookbook.