Skip to main content

Key-Value Pairs in TypeScript

Key-value pairs are a fundamental data structure in TypeScript, offering a flexible way to store and retrieve data. Whether you're working with objects, TypeScript map, or utility types, understanding how to define and manage key-value pairs is essential for building reliable applications.

In this article, we'll explore various approaches to define, manipulate, and iterate over key-value pairs in TypeScript, including interfaces, maps, and the Record<K, T> utility type.

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 an excellent choice for storing key-value pairs. Here's how you can create and use a Map:

// Create a new Map with specified key and value types
const userMap = new Map<string, number>();

// Add key-value pairs
userMap.set('Alice', 30);
userMap.set('Bob', 25);

// Retrieve values
console.log(userMap.get('Alice')); // Output: 30

// Check if a key exists
console.log(userMap.has('Charlie')); // Output: false

// Remove a key-value pair
userMap.delete('Bob');

// Get the size of the map
console.log(userMap.size); // Output: 1

Maps offer several advantages over regular objects:

  1. Keys can be of any type, not just strings and symbols
  2. Maps maintain insertion order when iterating
  3. Maps have built-in methods for size tracking and manipulation
  4. Better performance for frequent additions and removals

You can use foreach to iterate through a Map:

userMap.forEach((value, key) => {
console.log(`${key}: ${value}`);
});

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.

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.

Implementing a TypeScript Dictionary

A TypeScript dictionary is conceptually similar to a Map but typically implemented using plain JavaScript objects. Here's how to create a type-safe dictionary class:

class Dictionary<T> {
private items: { [key: string]: T };

constructor() {
this.items = {};
}

// Add or update an item
set(key: string, value: T): void {
this.items[key] = value;
}

// Retrieve an item
get(key: string): T | undefined {
return this.items[key];
}

// Check if a key exists
has(key: string): boolean {
return key in this.items;
}

// Remove an item
delete(key: string): boolean {
if (this.has(key)) {
delete this.items[key];
return true;
}
return false;
}

// Get all keys
keys(): string[] {
return Object.keys(this.items);
}

// Get all values
values(): T[] {
return Object.values(this.items);
}

// Get all entries
entries(): [string, T][] {
return Object.entries(this.items);
}

// Get the number of items
size(): number {
return Object.keys(this.items).length;
}

// Clear all items
clear(): void {
this.items = {};
}
}

This implementation leverages the TypeScript object type with an index signature to create a flexible container for key-value pairs. You can use it like this:

// Create a dictionary of user ages
const userAges = new Dictionary<number>();

// Add some entries
userAges.set("Alice", 30);
userAges.set("Bob", 25);

// Use the dictionary
console.log(userAges.get("Alice")); // Output: 30
console.log(userAges.has("Charlie")); // Output: false
console.log(userAges.size()); // Output: 2

// Iterate over all entries
userAges.entries().forEach(([name, age]) => {
console.log(`${name} is ${age} years old`);
});

This pattern works well with Convex's code spelunking approach, where you might need to create lookup tables for API endpoints or data mapping.

For scenarios requiring more complex typing, you can extend the Dictionary class with Interfaces:

interface User {
name: string;
age: number;
role: string;
}

const userDictionary = new Dictionary<User>();
userDictionary.set("user1", { name: "Alice", age: 30, role: "Admin" });

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

Understanding key-value pairs in TypeScript is essential for building reliable and maintainable applications. Whether you're using TypeScript Record<K, T> for type-safe objects, leveraging TypeScript map for better performance, or creating custom key-value implementations with index signatures, TypeScript provides robust tools to handle this common data structure.

Key benefits of using well-typed key-value pairs include:

  1. Better error detection during development
  2. Enhanced code readability and maintainability
  3. Improved IDE support with autocomplete suggestions
  4. More reliable refactoring capabilities