TypeScript Record

August 30, 202610 min read
Requirements:
ObjectsGenerics| Unions

Record<Keys, Value> builds an object type where every key in Keys maps to a value of type Value. That is the whole feature in one line.

What makes it worth a page of its own is the second thing it does. When Keys is a finite union, Record stops being a convenience and starts being a compile-time checklist — add a new member to the union and every record built from it breaks until you fill in the missing case. That property is why Record shows up in so many codebases, and it is the difference between a type that documents your data and a type that actually defends it.

The shape

Record takes two type parameters. The first is the set of keys, the second is the type every value must have.

type Role = 'admin' | 'editor' | 'viewer'
 
type RolePermissions = Record<Role, boolean>
 
const canPublish: RolePermissions = {
  admin: true,
  editor: true,
  viewer: false,
}

RolePermissions is exactly { admin: boolean; editor: boolean; viewer: boolean }. You did not write those three properties out, and you will not have to update them by hand later.

Record is a generic type, and for most people it is the first two-parameter generic they use in anger. Nothing exotic is happening — the two slots are just "which keys" and "what do they hold".

Why not an index signature

The obvious alternative is an index signature. It looks similar and behaves very differently.

type LooseFlags = { [key: string]: boolean }
 
const flags: LooseFlags = {
  admin: true,
  edtior: false, // ✅ accepted — the typo is a perfectly valid string key
}

An index signature says any string key is allowed. It cannot catch a typo, it cannot tell you a key is missing, and autocomplete has nothing to offer you. You have described the value type and thrown away everything you knew about the keys.

Swap in a union of keys and both problems go away.

type TeamRole = 'admin' | 'editor' | 'viewer'
 
// const typo: Record<TeamRole, boolean> = { admin: true, edtior: false, viewer: false }
// ❌ Object literal may only specify known properties, and 'edtior' does not exist in type 'Record<TeamRole, boolean>'
 
// const missing: Record<TeamRole, boolean> = { admin: true, editor: false }
// ❌ Property 'viewer' is missing in type '{ admin: boolean; editor: boolean; }'

That second error is the one that earns its keep. Six months from now somebody adds 'auditor' to the union, and TypeScript immediately points at every lookup table that has not accounted for it. You get exhaustiveness without writing a single check, and without anyone having to remember that the table exists.

This is also why the keys are almost always a union of string literals. Record<string, T> is legal and occasionally what you want, but it is an index signature wearing a nicer name — it gives you none of the guarantees above.

Record is a mapped type

Record is not compiler magic. It is a one-line mapped type declared in TypeScript's standard library, and you can rebuild it yourself:

type MyRecord<K extends keyof any, T> = {
  [P in K]: T
}
 
type Scores = MyRecord<'math' | 'science', number>
// { math: number; science: number }

The [P in K] part loops over every member of K and creates a property for it. The extends keyof any constraint limits K to things that can actually be object keys — string | number | symbol.

Knowing this matters because it tells you what else Record composes with. Anything that works on a mapped type works here: Partial, Readonly, Pick, key remapping. It is all the same machinery, so you are not learning a special case.

Keys from an enum

Enums pair naturally with Record for the same reason unions do — the member list is finite and known ahead of time.

enum Status {
  Idle = 'idle',
  Loading = 'loading',
  Done = 'done',
}
 
const statusLabels: Record<Status, string> = {
  [Status.Idle]: 'Waiting',
  [Status.Loading]: 'Working on it',
  [Status.Done]: 'Finished',
}

Add a fourth status and the object above becomes a compile error until you write a label for it. That is the exhaustive lookup table pattern, and it is the single most common real-world use of Record. Reducer maps, icon maps, colour maps, route maps — same shape every time.

Deriving the keys instead of writing them

You do not have to declare the key union by hand. If you already have a runtime object, keyof typeof gives you its keys as a type, and Record takes it from there.

const themeTokens = {
  background: '#0b0b0f',
  foreground: '#f5f5f5',
  accent: '#7c5cff',
} as const
 
type ThemeToken = keyof typeof themeTokens
 
const lightTheme: Record<ThemeToken, string> = {
  background: '#ffffff',
  foreground: '#111111',
  accent: '#5b3df5',
}

The as const keeps the literal types instead of widening everything to string, and the typeof operator turns the value into a type you can pull keys out of. Now themeTokens is the single source of truth: add a token there and every theme built with Record<ThemeToken, string> fails until it is filled in. This is worth reaching for whenever the key list already exists somewhere in your code — duplicating it into a hand-written union just gives you two things to keep in sync.

Record or an interface

Both describe object shapes, so the choice comes up constantly. The rule is short: if the values have different types, you want an interface.

interface AppUser {
  id: string
  age: number
  isActive: boolean
}

You cannot express that with Record — every value shares one type by definition. Reach for Record when the keys vary and the value type does not:

type Currency = 'usd' | 'eur' | 'gbp'
 
const exchangeRates: Record<Currency, number> = {
  usd: 1,
  eur: 0.92,
  gbp: 0.79,
}

A lookup table, a cache, a config keyed by environment, a set of handlers — all Record. A domain entity with named fields of different types — interface. The tell is whether you find yourself writing the same type after every colon.

Record or a Map

Record is a type; Map is a runtime data structure. They solve overlapping problems and the trade-off is real.

type UserId = string
 
const sessionsByUser = new Map<UserId, number>()
sessionsByUser.set('u_1', 3)
 
const knownCount = sessionsByUser.get('u_1') // number | undefined

Map is honest about missing keys — get always returns T | undefined, so you are forced to handle the gap. It also accepts any value as a key and preserves insertion order. Use it for data that grows at runtime: caches, memoisation, anything keyed by an ID you will not know until the program runs.

Record wins when the key set is fixed and known at compile time. It serialises to JSON for free, it destructures, and it gives you the exhaustiveness checking that Map cannot. Configuration and lookup tables are Record; runtime collections are Map.

Two gotchas worth knowing

Record<string, T> lies about lookups

By default TypeScript assumes any key you ask for is present.

const ages: Record<string, number> = { ada: 36 }
 
const graceAge = ages.grace // typed number — but it is undefined at runtime

TypeScript hands you number and moves on. Call .toFixed() on it and you get a runtime crash from a file that type-checked cleanly.

The fix is the noUncheckedIndexedAccess compiler flag, which makes every index lookup return T | undefined and forces you to check before using the value. It is off by default, including under strict, and it is worth turning on. The other fix is to avoid Record<string, T> in the first place — a finite key union does not have this problem, because every key really is there.

Partial keys need Partial

Record requires every key. When you genuinely want a subset — overrides, a sparse cache, optional per-key config — wrap it:

type AccessRole = 'admin' | 'editor' | 'viewer'
 
const overrides: Partial<Record<AccessRole, boolean>> = {
  admin: true, // ✅ the other two may be omitted
}

Partial<Record<K, V>> is common enough that it is worth recognising on sight. You keep the typo protection and the autocomplete, you just drop the completeness requirement. Reaching for Record<string, V> because a few keys were optional is the mistake this replaces.

The same wrapping trick works with Readonly when the table is a set of defaults nobody should be editing in place:

type FeatureFlag = 'beta' | 'darkMode'
 
const defaultFlags: Readonly<Record<FeatureFlag, boolean>> = {
  beta: false,
  darkMode: true,
}
 
// defaultFlags.beta = true
// ❌ Cannot assign to 'beta' because it is a read-only property

Both wrappers work because Record is a plain mapped type. There is no special combination rule to learn — you are just applying one transformation to the result of another, and they stack in any order you need.

Using Record in generic code

Because Record is an ordinary generic, it slots into your own signatures cleanly. A groupBy is the classic example:

function groupBy<T, K extends string>(
  items: T[],
  getKey: (item: T) => K,
): Record<K, T[]> {
  const result = {} as Record<K, T[]>
 
  for (const item of items) {
    const key = getKey(item)
    if (!result[key]) {
      result[key] = []
    }
    result[key].push(item)
  }
 
  return result
}

The as Record<K, T[]> assertion is doing real work: an empty object is not a valid Record yet, and this is one of the few places an assertion is honest — you are telling the compiler you will fill it in before returning.

Record also nests, which is how most localisation and theming setups end up typed:

type Locale = 'en' | 'de'
type Page = 'home' | 'about'
 
const titles: Record<Locale, Record<Page, string>> = {
  en: { home: 'Home', about: 'About' },
  de: { home: 'Startseite', about: 'Über uns' },
}

Miss a locale, miss a page, or typo either one, and the compiler tells you which. Adding a third language is a guided exercise rather than a hunt through the codebase.

Wrap up

Use Record<K, V> when many keys share one value type. Use a finite union or enum for K whenever you can — that is where the exhaustiveness checking comes from, and it is most of the value. Derive the union with keyof typeof if the keys already exist as a value. Fall back to Record<string, V> only for genuinely open-ended maps, and turn on noUncheckedIndexedAccess when you do.

If you want to feel how Record works from the inside, the Defined Partial Record challenge has you build a version of it at the type level, combining key mapping with optionality. It is a good test of whether the mapped-type explanation above actually landed.

Share this article

Become a TypeScript Pro

Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges

Practice with Challenges

Put your typescript record knowledge to the test with these related challenges.

#34857Defined Partial Record
Medium
#4Pick
Easy
#7Readonly
Easy
#3Omit
Medium
#2757PartialByKeys
Medium
#11Tuple to Object
Easy

Related Concepts

Concepts that build on or relate to typescript record.

Mapped TypesTypeScript GenericsUnion TypesInterfacesTypeScript Enums