TypeScript Record
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 | undefinedMap 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 runtimeTypeScript 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 propertyBoth 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.
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