TypeScript Record

Record<K, V> is a built-in TypeScript utility type that describes an object whose keys are all of type K and whose values are all of type V. It’s the type-level equivalent of a dictionary or hash map, built on top of a plain JavaScript object. You’ll reach for it constantly: lookup tables, configuration objects, grouping data by category, and mapping over a fixed set of known keys. Unlike a hand-rolled index signature, Record can force every key in a union of literal types to be present, giving you compile-time exhaustiveness checking that’s hard to get any other way.

Overview / How Record Works

Record<K, V> is defined in TypeScript’s standard library (lib.es5.d.ts) roughly like this:

type Record<K extends keyof any, T> = {
  [P in K]: T;
};

That’s a mapped type: for every member P in the key type K, it creates a property P of type T. The constraint K extends keyof any means K must be assignable to string | number | symbol — the only types JavaScript object keys can actually be.

There are two common ways people use Record, and it’s worth distinguishing them clearly:

  • Open dictionariesK is string or number. This models “any number of keys of this kind, all mapping to values of this type,” similar to how you’d use a Map or a plain JS object as a lookup table.
  • Closed, exhaustive mapsK is a union of string (or number) literals, such as "admin" | "editor" | "viewer". Here Record behaves like an interface with one required property per literal: the compiler demands that every key in the union is present, and rejects any key that isn’t in the union.

Structurally, a value typed as Record<K, V> is just an ordinary object — there’s nothing magical happening at runtime. The type only exists to help the compiler check that your object’s shape matches what you promised.

Syntax

The general form is:

Part Meaning
K The type of the keys. Must be assignable to string | number | symbol. In practice this is usually a union of string literals, string, number, or a keyof SomeType expression.
V The type every value must have. All keys share this same value type — Record can’t give different keys different value types the way a hand-written interface can.
type Keys = "a" | "b" | "c";

const example: Record<Keys, number> = {
  a: 1,
  b: 2,
  c: 3,
};

console.log(example);

Output:

{ a: 1, b: 2, c: 3 }

Here Keys is the literal union "a" | "b" | "c", so TypeScript requires the object to have exactly those three properties (no more, no fewer), each holding a number.

Examples

Example 1: A simple open dictionary

const inventory: Record<string, number> = {
  apples: 50,
  bananas: 30,
  cherries: 120,
};

console.log(inventory.apples);
console.log(Object.keys(inventory));

Output:

50
[ 'apples', 'bananas', 'cherries' ]

Because the key type is string, TypeScript doesn’t know or care which specific string keys exist — it just guarantees that whatever key you look up (existing or not) will type-check as a number. This is the “dictionary” style of Record: flexible, but it can’t catch typos in key names the way the literal-union style can.

Example 2: An exhaustive map keyed by a literal union

type Fruit = "apple" | "banana" | "cherry";

const priceInCents: Record<Fruit, number> = {
  apple: 120,
  banana: 50,
  cherry: 375,
};

function totalCostInCents(counts: Record<Fruit, number>): number {
  let total = 0;
  for (const fruit of Object.keys(counts) as Fruit[]) {
    total += counts[fruit] * priceInCents[fruit];
  }
  return total;
}

const cart: Record<Fruit, number> = { apple: 3, banana: 6, cherry: 2 };
console.log(totalCostInCents(cart));

Output:

1410

Because Fruit is a fixed union of three literals, both priceInCents and cart are forced to define all three keys — if you added a new fruit to the Fruit union tomorrow, every Record<Fruit, ...> object in your codebase would immediately fail to compile until you updated it. That’s exhaustiveness checking working for you.

Example 3: Grouping data with Record

interface User {
  id: number;
  name: string;
  role: "admin" | "editor" | "viewer";
}

const users: User[] = [
  { id: 1, name: "Ada", role: "admin" },
  { id: 2, name: "Grace", role: "editor" },
  { id: 3, name: "Alan", role: "admin" },
  { id: 4, name: "Linus", role: "viewer" },
];

const usersById: Record<number, User> = {};
for (const user of users) {
  usersById[user.id] = user;
}

console.log(usersById[2].name);

const usersByRole: Record<User["role"], User[]> = {
  admin: [],
  editor: [],
  viewer: [],
};

for (const user of users) {
  usersByRole[user.role].push(user);
}

console.log(usersByRole.admin.map((u) => u.name));

Output:

Grace
[ 'Ada', 'Alan' ]

This is one of the most useful real-world patterns: turning an array into a lookup structure. usersById uses number keys for an open id-based dictionary, while usersByRole uses User["role"] — an indexed access type that pulls the literal union "admin" | "editor" | "viewer" straight off the User interface, so the grouping object stays exhaustive and in sync with the interface automatically.

Under the Hood

When the compiler sees Record<K, V>, it expands the mapped type { [P in K]: V } immediately. If K is a union, TypeScript distributes over each member of the union and produces one required property per literal — which is exactly why a literal-union Record behaves like a required-properties interface. If K is string or number, the expansion instead produces a plain index signature ({ [key: string]: V }), which allows arbitrary keys.

Crucially, none of this exists at runtime. TypeScript types are erased entirely during compilation — the compiled JavaScript output for every example above is just ordinary object literals, loops, and property assignments, with no trace of Record, generics, or type annotations anywhere. Record is a compile-time-only contract that helps you (and your editor’s autocomplete) while you write the code; it does nothing to validate data that arrives at runtime, e.g. from JSON.parse or a network response.

It’s also worth contrasting Record with the runtime Map class. A Record is just a plain object wearing a type annotation — it inherits all the quirks of plain objects (keys are coerced to strings except for symbols, keys like "__proto__" or "constructor" can collide with the prototype chain, and key order follows JavaScript’s own enumeration rules). Map, by contrast, is a real data structure with guaranteed insertion-order iteration, any value (including objects) as a key, and no prototype-pollution risk. Reach for Record when you want a lightweight, JSON-serializable, string/number-keyed structure typed at compile time; reach for Map when you need genuine runtime map behavior.

Common Mistakes

Mistake 1: Forgetting that a literal-union Record requires every key

When K is a union of literals, Record isn’t optional-by-default — every key must be supplied, just like a required property on an interface.

type Fruit = "apple" | "banana" | "cherry";

const prices: Record<Fruit, number> = {
  apple: 1,
  banana: 2,
  // cherry is missing here
};

tsc error:

Property 'cherry' is missing in type '{ apple: number; banana: number; }' but required in type 'Record<Fruit, number>'.

The fix is simply to supply every key in the union:

type Fruit = "apple" | "banana" | "cherry";

const prices: Record<Fruit, number> = {
  apple: 1,
  banana: 2,
  cherry: 3,
};

console.log(prices.cherry);

Output:

3

Mistake 2: Trusting Record<string, V> lookups as always defined

With an open Record<string, V>, the compiler types every possible string key as V — even keys that were never actually assigned. Under plain --strict (without the separate noUncheckedIndexedAccess flag), this compiles cleanly but can crash at runtime:

const scores: Record<string, number> = { alice: 90, bob: 85 };
const carolScore = scores["carol"]; // type says number, but the value is actually undefined

try {
  console.log(carolScore.toFixed(2));
} catch (err) {
  console.log("Runtime error:", (err as Error).message);
}

Output:

Runtime error: Cannot read properties of undefined (reading 'toFixed')

The type system happily let us call .toFixed() on something that was actually undefined at runtime. The fix is to check for the key’s existence explicitly (or design the function to return V | undefined) before using the value:

const scores: Record<string, number> = { alice: 90, bob: 85 };

function getScore(name: string): number | undefined {
  return Object.prototype.hasOwnProperty.call(scores, name)
    ? scores[name]
    : undefined;
}

const carolScore = getScore("carol");
if (carolScore !== undefined) {
  console.log(carolScore.toFixed(2));
} else {
  console.log("No score recorded for carol");
}

Output:

No score recorded for carol

Alternatively, enabling the noUncheckedIndexedAccess compiler flag makes TypeScript type every open-dictionary lookup as V | undefined automatically, forcing you to handle the missing case everywhere in your codebase.

Best Practices

  • Use a literal-union key type (Record<"a" | "b", V>) whenever the full set of keys is known ahead of time — you get free exhaustiveness checking that catches missing or misspelled keys at compile time.
  • Use Record<string, V> or Record<number, V> only for genuinely open-ended, dynamic key sets, and consider enabling noUncheckedIndexedAccess in tsconfig.json so lookups are typed as V | undefined instead of silently assumed to exist.
  • Reach for Partial<Record<K, V>> when not every key in a known union is guaranteed to be populated — it makes every property optional while still restricting the keys to the union.
  • Derive key unions from existing types with keyof or indexed access (like User["role"]) instead of retyping the literals by hand, so the Record stays in sync automatically when the source type changes.
  • Avoid Record<string, any> as a shortcut for “I don’t know the shape.” If the shape is knowable, write a real interface; any throws away every type-safety benefit Record exists to provide.
  • Remember types are erased at runtime — validate data coming from JSON, APIs, or user input with a runtime check or schema library before trusting it matches your Record type.

Practice Exercises

  • Exercise 1: Define a union type Weekday covering the seven days of the week as string literals, then declare storeHours: Record<Weekday, boolean> representing whether a store is open each day. Write a function isOpenOn(day: Weekday): boolean that reads from it.
  • Exercise 2: Given an array of log entries typed as { level: "info" | "warn" | "error"; message: string }[], use Array.prototype.reduce to build a Record<"info" | "warn" | "error", number> that counts how many entries exist at each level. Make sure all three keys start at 0 even if a level never appears in the input.
  • Exercise 3: Model a simple in-memory cache using Partial<Record<string, { value: string; expiresAt: number }>>. Write a get(key: string) function that returns undefined when the key is missing, and reason about why Partial is necessary here instead of a plain Record<string, ...>.

Summary

  • Record<K, V> is a mapped type, { [P in K]: V }, built into TypeScript’s standard library.
  • When K is a union of literals, every key becomes required — you get compile-time exhaustiveness checking.
  • When K is string or number, Record behaves like an open dictionary/index signature with no guarantee a given key actually exists at runtime.
  • Types are fully erased at compile time — the emitted JavaScript is just a plain object, with no trace of Record or generics.
  • Use Partial<Record<K, V>> for maps where not every key is guaranteed to be present, and consider noUncheckedIndexedAccess for safer open dictionaries.
  • Record models a plain object; use Map instead when you need real runtime map behavior (guaranteed order, non-string keys, no prototype collisions).