Key-Value Pairs in TypeScript
You're pulling user data from an API and storing it in an object. Everything works fine until production, where you discover you can't reliably iterate over the keys without type errors, or worse—the object's grown so large that lookups are sluggish. Should you use a plain object? A Map? A Record type?
This guide covers the practical ways to work with key-value pairs in TypeScript—from choosing the right data structure to handling iteration type safety issues that trip up even experienced developers.
Defining a TypeScript Interface for Key-Value Pairs
You can define a TypeScript interface for a key-value pair using the following syntax:
interface KeyValuePair {
key: string;
value: any;
}
This interface creates a simple structure with a key property of type string and a value property that can be of any type.
For more type safety, you can use generics to specify the types of both keys and values:
interface KeyValuePair<K, V> {
key: K;
value: V;
}
// Example usage
const stringNumberPair: KeyValuePair<string, number> = {
key: 'age',
value: 30
};
By using generics, you ensure that the types of both the key and value are consistent throughout your application, reducing potential runtime errors. This approach is particularly useful when working with Typescript objects that need consistent typing.
If you're working on a project that uses Convex, this approach to typing key-value pairs integrates well with Convex's type system.
Using TypeScript Maps for Key-Value Storage
Maps in TypeScript are built for dynamic key-value storage with frequent updates. Here's a practical example using a shopping cart:
class ShoppingCart {
private items = new Map<string, number>();
addItem(productId: string, quantity: number): void {
const currentQty = this.items.get(productId) ?? 0;
this.items.set(productId, currentQty + quantity);
}
removeItem(productId: string): void {
this.items.delete(productId);
}
getTotal(): number {
let total = 0;
this.items.forEach((qty) => {
total += qty;
});
return total;
}
getTotalItems(): number {
return this.items.size; // Built-in size property
}
}
const cart = new ShoppingCart();
cart.addItem('product-123', 2);
cart.addItem('product-456', 1);
console.log(cart.getTotalItems()); // Output: 2
So, why would you reach for a Map over a plain object? Maps shine when:
- Keys can be any type, not just strings and symbols (you can use objects as keys)
- Insertion order is guaranteed when iterating
- Built-in methods like
.size,.has(),.delete()make manipulation cleaner - Better performance for frequent additions and removals, especially with large datasets
You can use foreach to iterate through a Map:
cart.items.forEach((quantity, productId) => {
console.log(`Product ${productId}: ${quantity} items`);
});
When working with Convex, Maps can be especially useful for client-side caching of query results, as described in complex filters in Convex.
Creating Key-Value Pairs with TypeScript's Record Type
The TypeScript Record<K, T> utility type provides a concise way to define objects with specific key and value types. It's perfect for creating strongly-typed key-value pair structures:
// Define an object where all keys are strings and values are numbers
type UserAges = Record<string, number>;
const ages: UserAges = {
'Alice': 30,
'Bob': 25,
'Charlie': 35
};
// You can also use literal types or unions for more specific keys
type UserRole = 'admin' | 'editor' | 'viewer';
type RolePermissions = Record<UserRole, string[]>;
const permissions: RolePermissions = {
admin: ['read', 'write', 'delete'],
editor: ['read', 'write'],
viewer: ['read']
};
The Record<K, T> type effectively creates an TypeScript index signature but with cleaner syntax. It's equivalent to:
type UserAges = {
[key: string]: number;
};
Record<K, T> is particularly useful when working with Convex's data modeling, as shown in their argument validation without repetition guide, where you can create consistent data shapes for both client and server validation.
For generic key-value pairs, you can use Record<K, T> with type parameters:
type KeyValueStore<K extends string | number | symbol, V> = Record<K, V>;
// Example with string keys and any values
const store: KeyValueStore<string, any> = {
userId: 123,
username: 'alice',
isActive: true
};
This approach combines the flexibility of JavaScript objects with TypeScript's type safety, making it ideal for configuration objects, caches, and lookup tables.
When to Choose: Record vs Map vs Plain Objects
Choosing the right key-value structure can prevent performance issues and type headaches. Here's a decision guide:
| Use Case | Best Choice | Why |
|---|---|---|
| Static config with known keys | Record<K, T> or interface | Compile-time type safety, no runtime overhead |
| Frequent additions/deletions | Map | Optimized for dynamic operations |
| Large datasets (1000+ entries) | Map | Better performance at scale |
| Need non-string keys | Map | Plain objects coerce keys to strings |
| Simple lookup table | Plain object | Lightest weight, familiar syntax |
| Preserving insertion order matters | Map | Guaranteed iteration order |
Need .size or built-in iteration | Map | More convenient API |
Here's a practical example showing when to switch from a plain object to a Map:
// DON'T: Plain object for frequently changing data
const cache: Record<string, any> = {};
function addToCache(key: string, value: any) {
cache[key] = value;
// Checking size requires Object.keys(cache).length - inefficient
}
// DO: Map for dynamic, frequently mutated data
const cache = new Map<string, any>();
function addToCache(key: string, value: any) {
cache.set(key, value);
// Built-in size property is O(1)
if (cache.size > 100) {
// Evict oldest entries
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
}
Rule of thumb: Start with Record<K, T> or a plain object for type-safe, static data. Switch to Map when you need frequent updates, large datasets, or non-string keys.
Iterating Over Key-Value Pairs in TypeScript Objects
There are several ways to iterate over key-value pairs in TypeScript objects. Here are the most common methods:
Using for...in Loop
The for...in loop is a straightforward way to iterate through object properties:
const userScores = {
Alice: 95,
Bob: 87,
Charlie: 92
};
for (const name in userScores) {
console.log(`${name}: ${userScores[name]}`);
}
// Output:
// Alice: 95
// Bob: 87
// Charlie: 92
Remember that for...in iterates over all enumerable properties, including those inherited from the prototype chain. To iterate only over an object's own properties, use hasOwnProperty():
for (const name in userScores) {
if (userScores.hasOwnProperty(name)) {
console.log(`${name}: ${userScores[name]}`);
}
}
Using Object.entries()
Array handling methods like Object.entries() convert an object into an array of key-value pairs:
const userScores = {
Alice: 95,
Bob: 87,
Charlie: 92
};
Object.entries(userScores).forEach(([name, score]) => {
console.log(`${name}: ${score}`);
});
This approach is often more concise and allows you to use array methods like TypeScript array map and reduce:
// Get an array of formatted strings
const formattedScores = Object.entries(userScores).map(
([name, score]) => `${name} scored ${score}`
);
// Calculate the average score
const averageScore = Object.entries(userScores).reduce(
(sum, [_, score]) => sum + score, 0
) / Object.keys(userScores).length;
When building applications with Convex, these iteration techniques can be combined with their types cookbook to create type-safe data transformations.
Type-Safe Iteration: Solving the Object.keys() Problem
If you've worked with TypeScript long enough, you've hit this frustrating issue: Object.keys() returns string[], not the actual union of your object's keys. This breaks type safety when you try to access values:
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3
};
// TypeScript Error: Element implicitly has an 'any' type
Object.keys(config).forEach((key) => {
console.log(config[key]); // Error! key is 'string', not keyof typeof config
});
Why does this happen? JavaScript allows objects to have extra properties at runtime, so TypeScript can't guarantee that the keys returned by Object.keys() are actually valid keys of your object type.
Here are three practical solutions:
Solution 1: Use Object.entries() (Recommended)
The cleanest approach is to avoid Object.keys() entirely and use Object.entries():
Object.entries(config).forEach(([key, value]) => {
console.log(`${key}: ${value}`); // Works! TypeScript infers the types correctly
});
Solution 2: Type Assertion with keyof typeof
If you need just the keys, cast them to the correct type:
(Object.keys(config) as Array<keyof typeof config>).forEach((key) => {
console.log(config[key]); // Now type-safe
});
You can create a reusable helper function for this:
function objectKeys<T extends object>(obj: T): Array<keyof T> {
return Object.keys(obj) as Array<keyof T>;
}
objectKeys(config).forEach((key) => {
console.log(config[key]); // Type-safe!
});
Solution 3: Type Guard for Maximum Safety
If you want to verify that a string is actually a valid key before using it:
function isValidKey<T extends object>(
key: string | number | symbol,
obj: T
): key is keyof T {
return key in obj;
}
const userInput = 'apiUrl'; // Might come from user input
if (isValidKey(userInput, config)) {
console.log(config[userInput]); // Type-safe access
}
Which should you use? For most cases, Object.entries() is your best bet. It's clean, type-safe, and gives you both keys and values. Use the type assertion approach when you specifically need just the keys, and the type guard when dealing with dynamic or user-provided keys.
The keyof Operator for Dynamic Key Access
The keyof operator lets you create type-safe functions that access object properties dynamically. It's especially useful when you need to work with keys but don't know which ones at compile time.
Here's a practical example—a type-safe property getter:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = {
id: 1,
name: 'Alice',
email: 'alice@example.com'
};
const userName = getProperty(user, 'name'); // Type: string
const userId = getProperty(user, 'id'); // Type: number
// TypeScript Error: Argument of type '"invalid"' is not assignable to parameter
getProperty(user, 'invalid'); // Compile-time error!
The magic happens with K extends keyof T, which constrains K to only be valid keys of T. TypeScript even infers the correct return type based on which key you pass.
Combining keyof with typeof
When working with object values (not types), combine keyof with typeof:
const apiConfig = {
baseUrl: 'https://api.example.com',
timeout: 5000,
retries: 3
};
type ConfigKey = keyof typeof apiConfig; // "baseUrl" | "timeout" | "retries"
function updateConfig(key: ConfigKey, value: string | number) {
apiConfig[key] = value as any; // Type assertion needed due to union type
}
updateConfig('timeout', 10000); // OK
updateConfig('invalid', 123); // Error: Argument of type '"invalid"' is not assignable
Building a Type-Safe Update Function
Here's a realistic example that validates API responses:
interface ApiResponse {
userId: number;
status: 'pending' | 'completed' | 'failed';
data: Record<string, any>;
}
function validateField<T, K extends keyof T>(
obj: T,
key: K,
validator: (value: T[K]) => boolean
): boolean {
return validator(obj[key]);
}
const response: ApiResponse = {
userId: 123,
status: 'completed',
data: { result: 'success' }
};
// Validate that userId is positive
const isValid = validateField(response, 'userId', (id) => id > 0);
// TypeScript knows the validator receives a number because userId is number
The keyof operator is your go-to tool when building generic, reusable functions that need to work with object properties in a type-safe way.
Typing Key-Value Pairs with TypeScript's Index Signature
TypeScript's index signature lets you define a type for objects with dynamic key-value pairs:
interface Dictionary<T> {
[key: string]: T;
}
// Example usage
const stringDictionary: Dictionary<string> = {
'name': 'Alice',
'occupation': 'Engineer',
'location': 'New York'
};
const numberDictionary: Dictionary<number> = {
'age': 30,
'experience': 8,
'salary': 120000
};
For objects that specifically use string keys, you can use the object with string keys pattern:
interface Config {
[key: string]: string | number | boolean;
}
const appConfig: Config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
enableCache: true
};
You can restrict the key types to specific strings using union types:
type ValidConfigKey = 'apiUrl' | 'timeout' | 'enableCache';
interface StrictConfig {
[key in ValidConfigKey]?: string | number | boolean;
}
const strictConfig: StrictConfig = {
apiUrl: 'https://api.example.com',
timeout: 5000
// enableCache is optional due to the '?' in the type definition
};
Index signatures integrate well with Convex's end-to-end TypeScript support, helping you create consistent type definitions across your backend and frontend code.
To specify different value types based on the key, you can use conditional types:
type ConfigValue<K extends string> =
K extends 'apiUrl' ? string :
K extends 'timeout' ? number :
K extends 'enableCache' ? boolean :
never;
type TypedConfig = {
[K in ValidConfigKey]: ConfigValue<K>;
};
const typedConfig: TypedConfig = {
apiUrl: 'https://api.example.com', // Must be a string
timeout: 5000, // Must be a number
enableCache: true // Must be a boolean
};
Converting an Array to a Key-Value Pair Object in TypeScript
Arrays and objects serve different purposes in TypeScript. While array handling is great for ordered collections, objects excel at key-value mappings. Here's how to convert an array of key-value pair objects into a single object:
// Array of key-value pair objects
const userArray = [
{ key: 'alice', value: { age: 30, role: 'developer' } },
{ key: 'bob', value: { age: 25, role: 'designer' } },
{ key: 'charlie', value: { age: 35, role: 'manager' } }
];
// Convert to a single object using reduce
const userObject = userArray.reduce((result, item) => {
result[item.key] = item.value;
return result;
}, {} as Record<string, any>);
console.log(userObject);
// Output:
// {
// alice: { age: 30, role: 'developer' },
// bob: { age: 25, role: 'designer' },
// charlie: { age: 35, role: 'manager' }
// }
The reduce method is particularly useful for this transformation. You can also create more specific typings:
interface User {
age: number;
role: string;
}
interface UserPair {
key: string;
value: User;
}
// Typed version of the conversion
const typedUserObject = userArray.reduce<Record<string, User>>((result, item) => {
result[item.key] = item.value;
return result;
}, {});
For the reverse operation—converting an object to an array of key-value pairs—use Object.entries() with Typescript array map:
Final Thoughts on Key-Value Pairs in TypeScript
Mastering key-value pairs in TypeScript comes down to choosing the right tool for your use case and understanding type safety patterns. Use Record<K, T> for static, compile-time structures. Reach for Map when you need dynamic operations, large datasets, or non-string keys. Handle iteration carefully with Object.entries() or type assertions to maintain type safety.
The techniques covered here—from keyof operators to type guards—aren't just theoretical. They prevent runtime errors, improve code maintainability, and give you confidence that your data structures won't break when your application scales.
Here's your quick reference guide:
- Static config? →
Record<K, T>or interface - Dynamic, frequently changing data? →
Map - Need to iterate? →
Object.entries()first, type assertions if you need keys only - Generic property access? →
keyofwith generics - User-provided keys? → Type guards with
key is keyof T