Dynamic keys: index signatures and Record
TypeScript index signatures and the Record utility type for objects whose keys come from data, not declared fields.
The previous lessons covered two object shapes whose keys you write by hand: type User = { id: string; email: string } and the positional tuple.
This lesson adds a third, where the keys come from data.
Picture three scenarios:
const userCache = { 'usr_01j8aa': { id: 'usr_01j8aa', email: 'a@example.com' }, 'usr_01j8ab': { id: 'usr_01j8ab', email: 'b@example.com' },};
const statusLabels = { draft: 'Draft', sent: 'Sent', paid: 'Paid',};A cache keyed by the IDs your database minted; a map from a finite set of statuses to display labels; and a third not shown, a JSON payload whose keys are whatever a webhook shipped. None declare their keys up front.
Each shape has a correct type, and reaching for the same one for all three is the common mistake.
Type the status lookup too loosely and a missing key ships, surfacing later as a broken label three components away.
Type the cache too tightly, listing every user ID at design time, and it stops compiling the moment a new user signs up.
TypeScript offers two forms for dynamic keys: the index signature ({ [key: string]: V }) and the Record<K, V> utility type.
The two forms: index signature and Record<K, V>
Section titled “The two forms: index signature and Record<K, V>”An index signature declares that every key of one type maps to a value of another: { [userId: string]: User }.
The userId label shows in editor tooltips but the type checker ignores it; string is the key constraint and User is the value type.
The Record<K, V> utility type is the same shape, written Record<string, User>.
It ships with TypeScript, so you import nothing.
type User = { id: string; email: string };
type UserCache = { [userId: string]: User };
const userCache: UserCache = { 'usr_01j8aa': { id: 'usr_01j8aa', email: 'a@example.com' }, 'usr_01j8ab': { id: 'usr_01j8ab', email: 'b@example.com' },};Reads “for any key of type string, the value is a User.” You’ll need this form for the mixed shapes later in this lesson.
type User = { id: string; email: string };
type UserCache = Record<string, User>;
const userCache: UserCache = { 'usr_01j8aa': { id: 'usr_01j8aa', email: 'a@example.com' }, 'usr_01j8ab': { id: 'usr_01j8ab', email: 'b@example.com' },};Reads “object whose keys are strings, whose values are User,” the same shape without the bracket noise.
When the key constraint is string, the two forms are interchangeable: identical hover, autocomplete, and errors, so choose by legibility.
Prefer Record<string, V>; reach for the index signature only where it is the syntax that fits, the mixed-fields case later in this lesson.
Record<LiteralUnion, V> and the completeness check
Section titled “Record<LiteralUnion, V> and the completeness check”The intro’s second shape was a status-to-label lookup.
const statusLabels = { draft: 'Draft', sent: 'Sent', paid: 'Paid',};These keys are finite: exactly 'draft', 'sent', and 'paid', the same union as an invoice’s status field earlier in this chapter.
Typed as Record<string, string>, the type system can’t tell whether 'paid' is present, since every string is a valid key.
Typed as Record<'draft' | 'sent' | 'paid', string>, a missing key is an error right where the object is written.
type Status = 'draft' | 'sent' | 'paid';
const labelsLoose: Record<string, string> = { draft: 'Draft', sent: 'Sent', // 'paid' missing — no error};
// @ts-expect-error — Property 'paid' is missingconst labelsStrict: Record<Status, string> = { draft: 'Draft', sent: 'Sent',};
const labels: Record<Status, string> = { draft: 'Draft', sent: 'Sent', paid: 'Paid',};Record<string, string> accepts anything and catches nothing. Any object of string keys and values passes, so the missing 'paid' draws no error. The bug ships: code reads labelsLoose.paid, gets undefined, and the UI shows a raw status code or crashes on a string method.
type Status = 'draft' | 'sent' | 'paid';
const labelsLoose: Record<string, string> = { draft: 'Draft', sent: 'Sent', // 'paid' missing — no error};
// @ts-expect-error — Property 'paid' is missingconst labelsStrict: Record<Status, string> = { draft: 'Draft', sent: 'Sent',};
const labels: Record<Status, string> = { draft: 'Draft', sent: 'Sent', paid: 'Paid',};Record<Status, string> requires every member of the union to appear. The error fires here at the assignment, in the file that defines the value, not three components away when something reads labels.paid. The @ts-expect-error acknowledges the missing 'paid'. This is the completeness check.
type Status = 'draft' | 'sent' | 'paid';
const labelsLoose: Record<string, string> = { draft: 'Draft', sent: 'Sent', // 'paid' missing — no error};
// @ts-expect-error — Property 'paid' is missingconst labelsStrict: Record<Status, string> = { draft: 'Draft', sent: 'Sent',};
const labels: Record<Status, string> = { draft: 'Draft', sent: 'Sent', paid: 'Paid',};Record<Status, string>, now complete. All three keys present, no error: the shape you ship. The payoff outlasts this site: add 'overdue' to Status, and every Record<Status, V> initializer becomes a compile error pointing at the missing key, with no grep on your part.
The rule: if the keys are finite and known at design time, type the object as Record<LiteralUnion, V>.
A union turns a generic shape into a checked one, and when it grows the resulting compile errors are the help: each points at a site to update, where the alternative is a missing key that compiles and breaks in production.
What index reads return under noUncheckedIndexedAccess
Section titled “What index reads return under noUncheckedIndexedAccess”The course’s tsconfig sets noUncheckedIndexedAccess: true.
Under this flag, any read through an index signature returns V | undefined, because TypeScript can’t assume a key is present just from the type.
The Record<LiteralUnion, V> form is the exception: every key in the union is guaranteed present, so the read returns V.
type User = { id: string; email: string };
const userCache: Record<string, User> = {};
const u = userCache['usr_01j8aa'];// ^? User | undefinedAn open-keyed object can’t prove the key is present, so the read returns User | undefined however obvious the key looks. Narrow before you use it: if (u) { ... }.
type Status = 'draft' | 'sent' | 'paid';
const labels: Record<Status, string> = { draft: 'Draft', sent: 'Sent', paid: 'Paid',};
const status: Status = 'draft';const label = labels[status];// ^? stringThe read returns string, no | undefined. The completeness check from the previous section guarantees every Status key is present, so the value is ready to use.
User | undefined versus string gives the rule: use Record<LiteralUnion, V> when the keys are finite and known, and Record<string, V> (or the index signature) when they are open.
The read-site difference appears only when K is a literal union; when K is string the two forms behave the same.
Mixed shapes: named fields plus a dynamic surface
Section titled “Mixed shapes: named fields plus a dynamic surface”An object can carry both named fields and a dynamic surface, like a config with a fixed name plus arbitrary metadata.
Only an index signature allows this; Record<K, V> has no slot for named fields.
type Metadata = { name: string; createdAt: string; [key: string]: string | number;};
const m: Metadata = { name: 'project-alpha', createdAt: '2026-05-26T10:00:00Z', retries: 3, region: 'us-east-1',};The rule: every named field must fit the index signature’s value type, which acts as a ceiling for the whole object.
Reading dynamic-keyed objects: existence checks
Section titled “Reading dynamic-keyed objects: existence checks”Under noUncheckedIndexedAccess, every read through an index signature returns V | undefined, so you narrow it before use.
type User = { id: string; email: string };
const cache: Record<string, User> = {};const key = 'usr_01j8aa';
const maybeUser = cache[key];if (maybeUser !== undefined) { // maybeUser is User here — narrowed by the explicit undefined check}
if (key in cache) { // The `in` check confirms the key exists at runtime, // but TypeScript still types cache[key] as User | undefined here. const u = cache[key];}The standard move is to pull the read into a const and test it against undefined; inside the branch, the const is typed as V.
The in operator confirms a key exists at runtime but does not narrow across an index signature, so a cache[key] read inside the branch stays V | undefined.
Use in to test existence, for example before writing to a key, and use the captured-read !== undefined form when you want the narrowed value.
Decide which form to reach for
Section titled “Decide which form to reach for”Sort each shape by whether its keys are open or finite; the form follows from that one answer.
Sort each shape by whether the keys are finite and known at design time, or open and minted at runtime. Drag each item into the bucket it belongs to, then press Check.
'draft' | 'sent' | 'paid') to display label'GET' | 'POST' | 'PUT' | 'DELETE') to handler'en' | 'es' | 'fr')Two items are subtler, and both turn out open.
The webhook payload is Record<string, unknown>: open keys, and values you can’t know until you parse them.
The Drizzle table is Record<string, Row>: open keys, since UUIDs are minted at insert time, but a typed $inferSelect row for the value.
Both are open at the key level, so both take the same form; only the value type differs.
External resources
Section titled “External resources”Official treatment of the index-signature syntax and the per-field-assignability rule from the mixed-shape section.
Reference for the Record utility type in the utility-types section. The canonical citation for the literal-union form.
Matt Pocock's walkthrough of the strict-mode flag this lesson leans on, with the same open-vs-finite divergence at the read site.
Dmitri Pavlutin's deep dive into the bracket syntax, the string-vs-number key coercion, and the per-field-assignability rule from the mixed-shape section.