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]: Tare structurally identical;Recordis shorter
Quick Example
// 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
constarray: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
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:
interface Config {
name: string;
version: number;
[key: string]: string | number; // now compatible
}Using T[string] instead of T[keyof T]
type Obj = { a: 1; b: 2 };
type Bad = Obj[string]; // Error: 'string' is not a valid index type
type Good = Obj[keyof Obj]; // 1 | 2string is too wide. Use keyof Obj or a specific literal union.
Writing to a readonly index signature
interface ReadDict {
readonly [key: string]: string;
}
const d: ReadDict = { a: "ok" };
d["b"] = "no"; // Error: index signature only permits readingDrop readonly if you need mutation. Keep it only when immutability is intentional.
Forgetting as const with array index access
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>inreq.params - Redux:
Action["payload"]extracts the payload type for a specific action variant - Lodash:
Dictionary<T>is[key: string]: Tinternally - Node.js:
process.envis 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:
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 stringK 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
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: undefinedTypeScript 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
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 itThe index access ApiResponses[T] ties the return type directly to the endpoint argument. No type assertion needed anywhere.
Advanced: Extracting Nested Types
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 readyA concise answer to help you respond confidently on this topic during an interview.