Dynamic keys — index signatures and Record<K, V>
TypeScript index signatures and the Record utility type for typing objects whose keys come from data rather than declared field names.
The last two lessons taught the two object shapes you author from declared field names: type User = { id: string; email: string } and the positional tuple. This lesson adds the third. Here are three scenarios that show what “dynamic keys” means in production code:
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',};The first is a cache keyed by user ID, and the second is a lookup from status to display label. The third shape this lesson covers isn’t shown above: a JSON payload parsed from the wire, where the keys are whatever the third party decided to send. None of these objects declare their keys up front the way id or email were declared in the previous lessons. Instead, the keys come from data: from the IDs your database minted, from a finite set of statuses your domain agreed on, or from the shape a webhook chose to ship.
Each of these three shapes has a correct type, and the goal of this lesson is to match each one to the right type. The common mistake is to reach for the same type for all three. Type the status lookup too loosely and a missing key ships, then surfaces later as a broken label three components away. Type the cache too tightly, listing every user ID at design time, and the type stops compiling the moment a new user signs up. TypeScript gives you two forms for dynamic keys: the index signature ({ [key: string]: V }) and the Record<K, V> utility type. By the end of this lesson you’ll have one rule for picking between them, along with the strict-mode behavior at the read site that makes the finite-key case the one to reach for.
The two forms: index signature and Record<K, V>
Section titled “The two forms: index signature and Record<K, V>”Begin with the syntax. An index signature is an object-type member that says “every key of this type maps to a value of this type.” You write it as { [userId: string]: User }. The userId part is a label, following the same all-or-nothing tooling rule as the previous lesson’s tuple labels: it’s pure documentation, surfaced in editor tooltips. The string is the key constraint, and the User is the value type.
The Record<K, V> utility type is the same idea wrapped in a generic: Record<string, User>. TypeScript ships it built-in; you don’t import anything.
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' },};This is the form you’ll meet in legacy types and in mixed shapes where named fields sit next to the dynamic surface (covered later in this lesson). The bracket syntax reads “for any key of type string, the value is a User.”
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' },};Identical shape at the type-system level. Record<string, User> reads “object whose keys are strings, whose values are User” without the bracket-syntax noise. The course prefers Record<string, V> for legibility and reaches for the index-signature form only when the mixed-shape case demands it.
The two forms are interchangeable at the type-system level when the key constraint is string. Any assignment that compiles against one compiles against the other, and either gets the same hover, the same autocomplete, and the same errors. The choice between them comes down to legibility: Record<string, V> reads more easily, so prefer it unless the index-signature syntax is the only one that fits the shape, which happens in the mixed-fields case later in this lesson.
Record<LiteralUnion, V> and the completeness payoff
Section titled “Record<LiteralUnion, V> and the completeness payoff”Now consider the second of the three shapes from the intro: the status-to-label lookup.
const statusLabels = { draft: 'Draft', sent: 'Sent', paid: 'Paid',};The keys here are not open. There are exactly three of them, 'draft', 'sent', and 'paid', the same literal union you used to type an invoice’s status field in the first lesson of this chapter. If you type this with Record<string, string>, the type system can’t tell whether 'paid' is present, because every string is a valid key. If you type it with Record<'draft' | 'sent' | 'paid', string>, a missing key becomes an error right where the object is written, the same place excess-property checks fire when an unexpected field appears.
Walk through the three states below, one at a time.
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. The key constraint is string, so any object whose keys are strings and values are strings passes. The 'paid' key is missing and TypeScript stays quiet. The bug then ships: the consumer reads labelsLoose.paid, gets undefined, and the UI displays the raw status code or, worse, crashes inside a string method called on undefined. The rest of the lesson shows how to keep this from happening.
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 literal-union key forces completeness at the literal site. The @ts-expect-error on the line above the declaration acknowledges the missing 'paid'. The compiler error fires right at the assignment site, in the file where the value is defined, not three components away when something tries to read labels.paid. This is the completeness check the literal-union form earns.
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 are present and there’s no error, so this is the shape you ship. The payoff continues past the literal site too. The day a teammate adds 'overdue' to the Status union, every Record<Status, V> initializer in the codebase becomes a compile error pointing at the missing key, so the type system finds every site you need to update without any grep on your part.
Here is the rule to take away: if the keys are finite and known at design time, type the object as Record<LiteralUnion, V>. This is the same posture as the literal-union rule for primitives, where the union is what turns a generic shape into a checked one. The completeness check at the literal site is the first half of the payoff. The second half is what happens at the read site, which the next section covers.
The noUncheckedIndexedAccess divergence
Section titled “The noUncheckedIndexedAccess divergence”The course’s tsconfig ships with noUncheckedIndexedAccess: true. This is the same flag introduced in the first lesson of the previous chapter, and one piece of the full strict-mode build-out the course revisits in chapter 024. The flag changes how index reads are typed. Under it, any read through an index signature returns V | undefined, because TypeScript no longer assumes the key is present just because the type says every key maps to a V. The Record<LiteralUnion, V> form is the exception: every key in the union is guaranteed present, so the read returns V directly.
This divergence is where the finite-key form earns its keep. The two tabs below make it visible at the type level.
type User = { id: string; email: string };
const userCache: Record<string, User> = {};
const u = userCache['usr_01j8aa'];// ^? User | undefinedUnder noUncheckedIndexedAccess, the read returns User | undefined regardless of whether the key looks “obvious.” The type system is being honest that an open-keyed object has no way to prove the key is present. Before you use u, you narrow it: if (u) { ... }. The full set of narrowing techniques lands two lessons from here, and the subset for dynamic-keyed objects is at the end of this lesson.
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 directly, with no | undefined. The completeness check from the previous section guaranteed that every key in the Status union is present, so the type system doesn’t widen at the read site. No narrowing is required: the value is ready to use the moment you read it.
Read the two ^? annotations side by side, User | undefined versus string, and the rule follows directly: use Record<LiteralUnion, V> when the keys are finite and known, and use Record<string, V> (or the index signature) when the keys are open. When K is string, the two forms behave the same, which is why the choice between them came down to legibility earlier. The read-site difference appears only when K is a literal union, and it is what makes the finite-key form worth reaching for.
Mixed shapes: known fields plus a dynamic surface
Section titled “Mixed shapes: known fields plus a dynamic surface”Occasionally a type has both named fields (the territory of the previous lessons) and a dynamic surface (this lesson’s territory) on the same object. One example is a serialized configuration with a name field plus arbitrary metadata; another is an event payload with a type discriminant plus whatever metadata the producer chose to attach. Only the index-signature syntax allows this combination. Record<K, V> has no slot for named fields, so a mixed shape has to use the index-signature form.
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',};One rule governs this: the index signature’s value type is a ceiling for every named field on the same object. If the index signature is string | number and a named field is boolean, TypeScript errors at the named field’s declaration. The dynamic-surface constraint reaches into the named-field section of the same type.
Reading dynamic-keyed objects: existence checks
Section titled “Reading dynamic-keyed objects: existence checks”Every read through an index signature under noUncheckedIndexedAccess returns V | undefined, so before you use the value, you narrow it. Two narrowing forms cover the dynamic-keyed cases. The full set, including typeof, instanceof, discriminant equality, and type predicates, lands two lessons from here.
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 !== undefined check on the read is the standard way to narrow an index-signature object. Pull the read into a const, check it against undefined, and the const is typed as V inside the branch. This is the form to reach for whenever you need the value typed without the | undefined.
The in operator is the right runtime check for whether a key exists on the object, since it’s what JavaScript provides for that question. It does not, however, narrow the type across an index signature: inside if (key in cache), a subsequent cache[key] read still types as V | undefined. Use in when you want to test existence, for example before writing to the key, and use the !== undefined form on a captured read when you want the narrowed type.
Decide which form to reach for
Section titled “Decide which form to reach for”You’ve seen both forms, the completeness payoff, and the read-site divergence. The exercise turns that into a sorting decision. For each shape, decide whether the keys are open (with no design-time enumeration, such as IDs, UUIDs, or anything from the wire) or finite (with every member known when you write the code, such as statuses, methods, or locales). The bucket choice follows from the 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 of these items are subtler. The webhook payload is Record<string, unknown>: the keys are open, and the values are also unknown until you parse them. That value type is the unknown type from the lesson on primitives, literals, and the four corners. The Drizzle lookup table is Record<string, Row>: the keys are open, since UUIDs are minted at insert time, but the value is the typed row from $inferSelect, the thing the database hands you. Both are open at the key level, so both take the same form. What differs between them is only the value type.
The whole rule comes down to two cases. For finite keys, use Record<LiteralUnion, V>, which gives you compile-time completeness, safe reads, and refactor errors when the union grows. For open keys, use Record<string, V> or the index signature, and narrow the read with in or !== undefined before using the value.
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.