Skip to main content

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.

ScenarioBest choiceWhy
Config object with known keys ('theme' | 'lang')Record<K, V>Exhaustiveness checking — TypeScript errors if you miss a key
Lookup from a JSON API responseIndex 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 runtimeMap<K, V>delete on plain objects is slow; Map.delete() is optimized
Need to know the entry count without iteratingMap<K, V>.size is O(1); Object.keys(obj).length is O(n)
Needs to round-trip through JSON.stringifyIndex signature or RecordMap doesn't serialize automatically
Extending or implementing the type with an interfaceIndex signatureRecord and Map don't work as interface implementations
Reusable generic utility that works with any key-value shapeGeneric Dictionary<K, V> typeSee 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 Record for simple lookups, configs, and anything that needs to round-trip through JSON

  • Choose Map when you need rich methods like has(), delete(), and size, 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.