Skip to main content

Index signatures and index access types in TypeScript

Index signatures let you type objects with unknown string or number keys. Index access types (T[K]) extract a specific property's type from an existing type definition at compile time. Two different tools, two different jobs.

Theory

TL;DR

  • Index signature ([key: string]: T) = a shape contract for objects with arbitrary keys
  • Index access type (T["key"]) = reading a property's type from another type
  • Analogy: a signature is a hotel keycard policy (any guest key opens a room of the same category); an access type is reading the room category from a floor plan
  • Signatures affect what you can write at runtime; access types produce zero runtime code
  • Record<string, T> and [key: string]: T are structurally identical; Record is shorter

Quick Example

typescript
// Index signature: any string key maps to a string value interface StringDict { [key: string]: string; } const dict: StringDict = { a: "apple" }; dict["b"] = "banana"; // OK, dynamic write // Index access type: reads the type of key "a" type Fruit = { a: "apple"; b: "banana" }; type AppleType = Fruit["a"]; // "apple" type AllValues = Fruit[keyof Fruit]; // "apple" | "banana"

Signatures let you write dynamic keys at runtime. Access types resolve during type checking and disappear entirely from the compiled JavaScript.

Key Difference

Index signatures define a contract: every value stored under any key must match the declared type, and TypeScript enforces this during assignment. Index access types do nothing at runtime. User["email"] is a type-level operation that resolves to whatever type email holds in User. You can chain them freely: User["address"]["city"] drills two levels into a nested type without touching any runtime value.

When to Use

  • Config or settings objects with user-defined keys: [key: string]: unknown
  • Pure string-to-value dictionaries: Record<string, T> (easier to read than a raw signature)
  • Extracting a prop type for a generic function: T[K]
  • Getting all value types from an interface: T[keyof T]
  • Typing elements of a const array: typeof arr[number]
  • Objects with only known properties and no dynamic keys: skip signatures entirely

How the Compiler Handles This

TypeScript applies excess property checking on fresh object literals. Assigning { a: 1, b: "oops" } to [key: string]: number fails immediately because "oops" is not a number. After assignment through a variable (structural check), TypeScript widens unknown keys to the signature type without complaint. Index access types go through the type mapper: T[K] is substituted during constraint solving, with no emitted code. Both features are fully erased in the JavaScript output.

One thing that comes up repeatedly in production code: mixing known properties with a string index signature forces all declared props to satisfy the signature type. This usually pushes teams toward Record for pure dictionaries and separate plain interfaces for structured data.

Common Mistakes

Known property conflicts with the index signature

typescript
interface Config { name: string; version: number; // Error: number is not assignable to string [key: string]: string; }

Every declared property must satisfy the signature. Fix by widening the signature type:

typescript
interface Config { name: string; version: number; [key: string]: string | number; // now compatible }

Using T[string] instead of T[keyof T]

typescript
type Obj = { a: 1; b: 2 }; type Bad = Obj[string]; // Error: 'string' is not a valid index type type Good = Obj[keyof Obj]; // 1 | 2

string is too wide. Use keyof Obj or a specific literal union.

Writing to a readonly index signature

typescript
interface ReadDict { readonly [key: string]: string; } const d: ReadDict = { a: "ok" }; d["b"] = "no"; // Error: index signature only permits reading

Drop readonly if you need mutation. Keep it only when immutability is intentional.

Forgetting as const with array index access

typescript
const roles = ["admin", "editor"]; // string[] type Role = typeof roles[number]; // string (too wide) const rolesConst = ["admin", "editor"] as const; type RoleConst = typeof rolesConst[number]; // "admin" | "editor"

Without as const, TypeScript infers string[] and the index access gives back string instead of a literal union.

Real-world Usage

  • React: React.CSSProperties[keyof React.CSSProperties] extracts all possible style value types
  • Express: route params typed as Record<string, string> in req.params
  • Redux: Action["payload"] extracts the payload type for a specific action variant
  • Lodash: Dictionary<T> is [key: string]: T internally
  • Node.js: process.env is typed as { [key: string]: string | undefined }

Follow-up Questions

Q: What is the difference between [key: string]: T and Record<string, T>?
A: Structurally identical. Record<string, T> expands to the same index signature. Most teams prefer Record for readability since it signals intent without the bracket syntax.

Q: Why does keyof on a string-indexed interface return string | number?
A: JavaScript coerces numeric keys to strings internally, so TypeScript includes number to cover the case where obj[0] is equivalent to obj["0"].

Q: Can you chain index access types?
A: Yes. User["address"]["city"] resolves address first, then looks up city on that result. Works for any depth as long as each step is a valid key.

Q: How does typeof arr[number] work with as const arrays?
A: as const turns the array into a readonly tuple with literal element types. Indexing with number produces a union of all those literal types.

Q: Write a type-safe get function using index access types.
A:

typescript
function get<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const user = { id: "1", name: "Alice" }; const name = get(user, "name"); // inferred as string

K extends keyof T constrains the key to valid ones, and T[K] preserves the exact return type without any type assertion.

Examples

Basic: Typed Dictionary

typescript
interface Translations { [key: string]: string; } const i18n: Translations = { hello: "hi", goodbye: "bye", }; const greeting = i18n["hello"]; // type: string, value: "hi" const missing = i18n["nope"]; // type: string, actual value: undefined

TypeScript cannot know which keys exist at runtime. If you want the type to reflect possible undefined, change the signature to [key: string]: string | undefined.

Intermediate: API Endpoint Typing

typescript
interface User { id: string; name: string } interface Post { id: number; title: string } interface ApiResponses { "/users": { users: User[] }; "/posts": { posts: Post[] }; } async function fetchData<T extends keyof ApiResponses>( endpoint: T ): Promise<ApiResponses[T]> { const res = await fetch(endpoint); return res.json(); } const data = await fetchData("/users"); // TypeScript infers: data is { users: User[] } // Change endpoint to "/posts" and the return type changes with it

The index access ApiResponses[T] ties the return type directly to the endpoint argument. No type assertion needed anywhere.

Advanced: Extracting Nested Types

typescript
interface Theme { colors: { primary: string; secondary: string; }; spacing: { sm: number; md: number; lg: number; }; } type ThemeColors = Theme["colors"]; // { primary: string; secondary: string } type SpacingKeys = keyof Theme["spacing"]; // "sm" | "md" | "lg" type SpacingValue = Theme["spacing"][SpacingKeys]; // number // Generic helper to extract any section type Section<T, K extends keyof T> = T[K]; type ColorSection = Section<Theme, "colors">; // same as Theme["colors"]

Chaining index access types avoids duplicating interface definitions. In design system code, you see this pattern constantly when building typed theme utilities.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?