TypeScript Key Value Pair: Record, Map and Typed Pairs

A TypeScript key value pair is usually stored in a plain object typed with Record<K, V> or an index signature, or in a Map<K, V> when keys change often or aren’t strings. Both give you typed lookups by key:

const capitals: Record<string, string> = { CA: "Sacramento", TX: "Austin" };
const zipToCity = new Map<number, string>([[78701, "Austin, TX"]]);

My test machine ran TypeScript 7.0.2 and Node.js v22.22.2 with strict mode on, so what you see under the code is real terminal output, not a guess.

I’ll start with Record and index signatures, move on to Map, then cover looping, typed pair interfaces, enum keys and how to pick the right structure.

Store key value pairs in an object with Record

Record<K, V> says “an object whose keys are K and whose values are V“. When K is a union of string literals, TypeScript checks that every key is present and no others sneak in.

Here’s a lookup of state codes to capitals with one key left out, plus a write to a key that isn’t in the union:

type StateCode = "CA" | "TX" | "NY" | "FL";

const capitals: Record<StateCode, string> = {
  CA: "Sacramento",
  TX: "Austin",
  NY: "Albany",
};

capitals.WA = "Olympia";

Output:

kv_record.ts(3,7): error TS2741: Property 'FL' is missing in type '{ CA: string; TX: string; NY: string; }' but required in type 'Record<StateCode, string>'.
kv_record.ts(9,10): error TS2339: Property 'WA' does not exist on type 'Record<StateCode, string>'.
Command Prompt output of tsc rejecting a TypeScript Record key value pair object with a missing key and an unknown key
The compiler flags the missing FL key and the unknown WA key before the code ever runs.

Add the missing entry and drop the bad write, and it compiles. You read values with dot notation or brackets, and a variable typed as StateCode works as a key too:

type StateCode = "CA" | "TX" | "NY" | "FL";

const capitals: Record<StateCode, string> = {
  CA: "Sacramento",
  TX: "Austin",
  NY: "Albany",
  FL: "Tallahassee",
};

capitals.TX = "Austin, TX";               // update a value
console.log(capitals.CA);
console.log(capitals["TX"]);

const code: StateCode = "FL";
console.log(`${code} -> ${capitals[code]}`);

Output:

Sacramento
Austin, TX
FL -> Tallahassee

That’s the main reason I reach for Record with a union: forget a key and the build fails. I dig into the utility type itself in TypeScript Record explained.

Use an index signature when keys aren’t known ahead

Sometimes you only know the shape of the values, like prices keyed by SKU from an API. An index signature such as [sku: string]: number allows any string key with a number value. Record<string, number> means the same thing.

interface PriceList {
  [sku: string]: number;
}

const prices: PriceList = {
  "COF-12": 14.99,
  "MUG-01": 9.5,
};

prices["TEA-20"] = 6.25;                  // add a new pair
delete prices["MUG-01"];                  // remove a pair

console.log(prices);
console.log("has TEA-20:", "TEA-20" in prices);

const missing = prices["HAT-99"];         // typed as number
console.log("HAT-99:", missing);
console.log("with fallback:", (prices["HAT-99"] ?? 0).toFixed(2));

Output:

{ 'COF-12': 14.99, 'TEA-20': 6.25 }
has TEA-20: true
HAT-99: undefined
with fallback: 0.00

Look at the last two lines. A missing key gives undefined at runtime, yet TypeScript still types missing as number. Calling missing.toFixed(2) would crash, which is why I added the ?? 0 fallback.

If the API’s fields are actually fixed, an index signature throws that knowledge away. Give the response a real interface instead, as shown in converting JSON to a TypeScript interface, and keep index signatures for truly open-ended keys.

You can make the compiler catch this. Turn on noUncheckedIndexedAccess and index-signature reads become number | undefined:

const prices: { [sku: string]: number } = { "COF-12": 14.99 };

const price = prices["HAT-99"];
console.log(price.toFixed(2));

Output:

kv_unchecked.ts(4,13): error TS18048: 'price' is possibly 'undefined'.

Now you’re forced to handle the missing case with ??, an if, or the in operator. More ways to test for a key are in checking if a key exists in a TypeScript object.

Store key value pairs in a TypeScript Map

A Map<K, V> is a real key value collection rather than an object pretending to be one. Keys can be any type (numbers, objects, dates), insertion order is kept, and size is built in.

This one maps ZIP codes, as numbers, to city names:

const zipToCity = new Map<number, string>([
  [10001, "New York, NY"],
  [60601, "Chicago, IL"],
]);

zipToCity.set(78701, "Austin, TX");
zipToCity.set(98101, "Seattle, WA");
zipToCity.set(60601, "Chicago (Loop), IL");   // same key: value replaced

console.log("get 78701:", zipToCity.get(78701));
console.log("get 99999:", zipToCity.get(99999));
console.log("has 98101:", zipToCity.has(98101));
console.log("size:", zipToCity.size);

zipToCity.delete(10001);
for (const [zip, city] of zipToCity) {
  console.log(zip, "=>", city);
}
console.log("keys:", [...zipToCity.keys()]);

Output:

get 78701: Austin, TX
get 99999: undefined
has 98101: true
size: 4
60601 => Chicago (Loop), IL
78701 => Austin, TX
98101 => Seattle, WA
keys: [ 60601, 78701, 98101 ]
Command Prompt output of a TypeScript Map with number ZIP code keys showing set, get, has, size, delete and a for...of loop
get() returns undefined for a missing key, setting an existing key replaces its value, and the loop runs in insertion order.

get() is typed as string | undefined, so strict mode already makes you check it. To turn a list of records into a Map in one step, see creating a Map from an array in TypeScript.

The [zip, city] destructuring in that loop works because a Map yields its entries as two-item arrays. It’s the same syntax you’d use with any iterable, covered in for…of loops in TypeScript.

The key methods you’ll use most:

MethodWhat it doesReturns
set(key, value)Adds or replaces a pairThe Map (chainable)
get(key)Reads a valueV | undefined
has(key)Checks for a keyboolean
delete(key)Removes a pairboolean
sizeCounts the pairsnumber
clear()Removes every pairundefined

How do you loop through key value pairs in TypeScript?

Maps iterate directly with for...of, as shown above. Objects need Object.keys(), Object.values() or Object.entries(), and the first one trips up almost everyone:

const sales = { Boston: 1250.5, Denver: 980.25, Phoenix: 1120.75 };

for (const city of Object.keys(sales)) {
  console.log(city, sales[city]);
}

Output:

kv_keys.ts(4,21): error TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ Boston: number; Denver: number; Phoenix: number; }'.
  No index signature with a parameter of type 'string' was found on type '{ Boston: number; Denver: number; Phoenix: number; }'.
Command Prompt output of tsc error TS7053 when indexing a TypeScript object with a key from Object.keys
Object.keys() returns string[], so sales[city] fails with TS7053.

TypeScript types Object.keys() as string[] on purpose, because an object can carry extra properties its type doesn’t mention. The fix is usually Object.entries(), which hands you the key and the typed value together:

const sales = { Boston: 1250.5, Denver: 980.25, Phoenix: 1120.75 };

for (const [city, amount] of Object.entries(sales)) {
  console.log(`${city.padEnd(8)} $${amount.toFixed(2)}`);
}

for (const city of Object.keys(sales) as (keyof typeof sales)[]) {
  console.log(city, "->", sales[city]);
}

const total = Object.values(sales).reduce((sum, n) => sum + n, 0);
console.log("total:", `$${total.toFixed(2)}`);

Output:

Boston   $1250.50
Denver   $980.25
Phoenix  $1120.75
Boston -> 1250.5
Denver -> 980.25
Phoenix -> 1120.75
total: $3351.50

The as (keyof typeof sales)[] cast also works when you know the object has no extra keys. I compare for...in and the other options in iterating over objects in TypeScript.

Create a typed KeyValuePair interface with generics

When you need a list of pairs rather than a lookup, like user settings from a database, a small generic interface keeps the key and value types consistent everywhere:

interface KeyValuePair<K, V> {
  key: K;
  value: V;
}

const settings: KeyValuePair<string, string | boolean>[] = [
  { key: "theme", value: "dark" },
  { key: "emailAlerts", value: true },
  { key: "timeZone", value: "America/Chicago" },
];

const theme = settings.find((s) => s.key === "theme");
console.log("theme:", theme?.value);

type Pair = [string, number];
const scores: Pair[] = [["Ava", 92], ["Liam", 85], ["Mia", 97]];

const asObject = Object.fromEntries(scores);
const asMap = new Map(scores);
console.log(asObject);
console.log(asMap);
console.log("back to pairs:", Object.entries(asObject));

Output:

theme: dark
{ Ava: 92, Liam: 85, Mia: 97 }
Map(3) { 'Ava' => 92, 'Liam' => 85, 'Mia' => 97 }
back to pairs: [ [ 'Ava', 92 ], [ 'Liam', 85 ], [ 'Mia', 97 ] ]

The tuple form [string, number] is handy because Object.fromEntries() and the Map constructor both accept an array of those pairs directly. Object.entries() goes the other way.

Searching an array of pairs with find() is a linear scan, though. If you look values up often, convert the pairs to an object or a Map first. For the reverse trip, see converting an object to an array in TypeScript.

Read and update values by key with keyof

Once you’ve got a typed object, you’ll often want a helper that reads or writes any property by name. keyof T is the union of an object’s keys, and T[K] is the value type for key K, so the compiler knows exactly what comes back:

interface Store {
  city: string;
  state: string;
  revenue: number;
}

const store: Store = { city: "Denver", state: "CO", revenue: 48250.75 };

function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

function setValue<T, K extends keyof T>(obj: T, key: K, value: T[K]): void {
  obj[key] = value;
}

const revenue = getValue(store, "revenue");   // typed as number
console.log("revenue:", revenue.toFixed(2));

setValue(store, "city", "Boulder");
console.log(store);

Output:

revenue: 48250.75
{ city: 'Boulder', state: 'CO', revenue: 48250.75 }

getValue(store, "revenue") returns a number, not any. The same helper rejects a wrong value type and a key that doesn’t exist:

interface Store {
  city: string;
  revenue: number;
}

function setValue<T, K extends keyof T>(obj: T, key: K, value: T[K]): void {
  obj[key] = value;
}

const store: Store = { city: "Denver", revenue: 48250.75 };
setValue(store, "revenue", "a lot");
setValue(store, "zip", "80202");

Output:

kv_keyof_bad.ts(11,28): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
kv_keyof_bad.ts(12,17): error TS2345: Argument of type '"zip"' is not assignable to parameter of type 'keyof Store'.

That’s the pattern libraries use for typed form state and settings objects. It also pairs nicely with type narrowing in TypeScript when a value can be one of several types.

Use enum values as keys

If your keys come from a fixed set your code already models as an enum, combine the enum with Record. TypeScript then requires a value for every member, so adding a new payment method later breaks the build until you add its fee.

enum PaymentMethod {
  Card = "CARD",
  PayPal = "PAYPAL",
  ACH = "ACH",
}

const fees: Record<PaymentMethod, number> = {
  [PaymentMethod.Card]: 0.029,
  [PaymentMethod.PayPal]: 0.0349,
  [PaymentMethod.ACH]: 0.008,
};

const order = 200;
for (const method of Object.values(PaymentMethod)) {
  console.log(`${method.padEnd(7)} fee on $${order.toFixed(2)}: $${(order * fees[method]).toFixed(2)}`);
}

Output:

CARD    fee on $200.00: $5.80
PAYPAL  fee on $200.00: $6.98
ACH     fee on $200.00: $1.60

String enums keep the keys readable at runtime (CARD, not 0), and Object.values() on a string enum gives just its values. A string-literal union does the same job with less output; I weigh the two in TypeScript enums.

Record vs Map vs index signature: which should you use?

They overlap, but a few runtime details decide it. Watch what JSON.stringify() does with each, and what a plain object inherits:

const asRecord: Record<string, number> = { Dallas: 3, Miami: 5 };
const asMap = new Map<string, number>([["Dallas", 3], ["Miami", 5]]);

console.log("Record JSON:", JSON.stringify(asRecord));
console.log("Map JSON:   ", JSON.stringify(asMap));
console.log("Map fixed:  ", JSON.stringify(Object.fromEntries(asMap)));

console.log("Record size:", Object.keys(asRecord).length, "| Map size:", asMap.size);

const plain: Record<string, string> = {};
console.log("plain object has toString?", "toString" in plain);
console.log("Map has toString?", new Map<string, string>().has("toString"));

Output:

Record JSON: {"Dallas":3,"Miami":5}
Map JSON:    {}
Map fixed:   {"Dallas":3,"Miami":5}
Record size: 2 | Map size: 2
plain object has toString? true
Map has toString? false
Command Prompt output comparing a TypeScript Record and Map: JSON.stringify of a Map prints empty braces, plus size and inherited key checks
A Map serializes to {} unless you convert it with Object.fromEntries() first.

That toString line matters for user-supplied keys: a plain object inherits properties from Object.prototype, so in can say yes to a key nobody set. A Map only knows what you put in it.

OptionBest forWatch out for
Record<Union, V>A fixed, known set of keysEvery key must be filled in
Index signature / Record<string, V>JSON data with unknown keysMissing keys typed as V
Map<K, V>Frequent adds and deletes, non-string keysDoesn’t serialize to JSON as-is
KeyValuePair<K, V>[]Ordered lists and duplicatesSlow lookups on big lists

Copying works differently too. Spread a Record with { ...capitals }, but copy a Map with new Map(zipToCity), since spreading a Map into braces gives an empty object. Both are shallow copies; I explain why in the TypeScript spread operator.

My rule of thumb: Record for config and lookup tables, Map for caches and anything that grows at runtime, and a pair array only when order or duplicate keys matter.

Here are a few more guides that pair well with this one:

Frequently asked questions

What is the best way to define a key value pair in TypeScript?

For a lookup object, use Record<K, V>. For dynamic data or non-string keys, use Map<K, V>. For a single pair, a generic { key: K; value: V } interface or a [K, V] tuple works well.

Is Record the same as an index signature?

Record<string, V> and { [key: string]: V } behave the same. Record becomes stricter when K is a union of literals, because then every key is required.

How do I add a key value pair to an object in TypeScript?

Assign it with bracket or dot notation, for example prices["TEA-20"] = 6.25. The object’s type must allow that key, through an index signature or a Record that includes it.

Why does Object.keys() return string[] instead of the object’s keys?

Objects can hold extra properties their type doesn’t list, so TypeScript can’t promise the keys are only keyof T. Use Object.entries() or cast when you’re sure.

How do I convert a Map to a plain object?

Call Object.fromEntries(map). It works when the keys are strings or numbers, and the result serializes with JSON.stringify().

Where is the Record type documented?

See Record<Keys, Type> in the TypeScript handbook’s utility types page.