5.6 Index Signatures: Objects with Dynamic Keys
Right, so you’ve got your nice, structured object types. You know, the ones where you define every property by name, like a well-organized bookshelf. But what about when you’re dealing with data that’s more… chaotic? Maybe you’re parsing a config file where users can add arbitrary keys, or you’re dealing with API responses that are basically a bag of key-value pairs. This is where TypeScript’s index signatures come in. They’re your way of telling the type system, “Look, I don’t know every key this object will have, but I can promise you that whatever keys it does have, their values will be of this specific type.”
Think of it as defining the rules of the game, not the players. You’re saying, “Any key you throw at this object must be a string (or a number), and its value must be a string.” The object becomes a dictionary or a map, but with type safety bolted on.
Here’s the syntax. It looks a bit like you’re defining a property, but the property name is replaced with a type inside square brackets:
interface StringDictionary {
[key: string]: string; // Index signature
}
const myDict: StringDictionary = {
greeting: "hello",
farewell: "goodbye",
// 42: "answer", // This is actually allowed! (We'll get to this weirdness in a sec)
};
// This works perfectly.
myDict["newKey"] = "new value";
const farewellMessage = myDict["farewell"]; // Type: string
// This will cause a type error. Thank you, TypeScript!
myDict["errorKey"] = 42; // Error: Type 'number' is not assignable to type 'string'.
The Key Conundrum: string vs. number
You’ll notice the index signature uses [key: string]. The key part is just a label for our human brains; you could call it [arbitraryDynamicProperty: string] and TypeScript wouldn’t care. The important bit is the type after the colon inside the brackets: it must be either string or number (or a union of literal types, but that’s a more advanced and less common trick).
Now, here’s the first bit of TypeScript weirdness to wrap your head around: in JavaScript, object keys are always strings. Even when you use a number, like obj[42], JavaScript immediately converts that number into the string "42". TypeScript, in a somewhat controversial attempt to mimic array behavior, allows number index signatures as a special case.
interface NumberIndexArray {
[index: number]: string; // Indexing by number
}
const myArrayLikeThing: NumberIndexArray = {
0: "first",
1: "second",
};
const firstItem = myArrayLikeThing[0]; // Type: string
The real fun begins when you combine them. If you have both a string index signature and a number one, the type of the number index’s value must be a subtype of the string index’s value. Why? Because myObj[42] is the same as myObj["42"]. The value you get back from a numeric lookup must be compatible with the value you’d get from the equivalent string lookup.
interface Problematic {
[key: string]: string;
[index: number]: number; // Error: 'number' index type 'number' is not assignable to 'string' index type 'string'.
}
interface Correct {
[key: string]: string | number; // The string index is the wider type
[index: number]: string; // The number index is a subtype (string is assignable to string | number)
}
The Rigid Value Type and The Pitfall
This is the most important—and most restrictive—rule: Once you define an index signature, all explicit properties must conform to it. The index signature sets the rules for the entire object.
interface BrokenDictionary {
[key: string]: string;
name: string; // This is fine
age: number; // ERROR: Property 'age' of type 'number' is not assignable to 'string' index type 'string'.
}
This is a huge “gotcha”. It means you can’t have a few known properties of one type and then a dynamic set of another. To fix the example above, your index signature’s value type must be a union (string | number) that encompasses all possible property types.
interface WorkingDictionary {
[key: string]: string | number; // Now we cover our bases
name: string; // OK
age: number; // OK
}
const me: WorkingDictionary = {
name: "The Reader",
age: 30,
arbitraryDynamicKey: "some string", // Still works
anotherOne: 100, // Also works
};
Best Practice: Combine with Explicit Properties
The most powerful and common pattern is to use an index signature alongside your known properties. This gives you the best of both worlds: type safety and autocompletion for the known stuff, and flexibility for the rest.
interface ApiResponse {
// Known properties
status: "success" | "failure";
code: number;
// Dynamic, user-defined data payload
data: {
[key: string]: unknown; // The safest choice for truly unknown data
};
}
const response: ApiResponse = await fetchSomeData();
console.log(response.status); // Nice and safe
console.log(response.data.anyRandomThingTheServerSent); // Type: unknown, so we have to check it before using it (which is correct!)
The choice of unknown for the data index signature is a best practice. Using any would be a cop-out, defeating the purpose. unknown forces you to perform type checks or assertions before using the values, which is exactly what you should be doing with data you don’t control. It’s TypeScript’s way of making you put on your safety gear before entering the construction zone.