Skip to main content

TypeScript Index Signatures

You're processing an API response where each user has different custom fields. You write userFields.preferences, and it works in development. Then production crashes because preferences is undefined for some users. TypeScript didn't catch it because you typed the object as any. This is exactly where index signatures come in—they let you define objects with dynamic property names while keeping type safety intact.

Index signatures specify both the type of keys and the type of their values within an object. You can use them to create dictionaries, configuration objects, and other dynamic data structures without losing the benefits of TypeScript's type system.

Introduction to Index Signatures

Index signatures help you define objects with properties that don't have predefined names but have known types. Here's a practical example:

interface ApiResponse {
[field: string]: string;
}

const userMetadata: ApiResponse = {
userId: "abc123",
sessionId: "xyz789",
ipAddress: "192.168.1.1"
};

// You can access any string property
console.log(userMetadata.userId); // "abc123"
console.log(userMetadata.customField); // undefined (but TypeScript thinks it's a string)

This pattern is common when working with configuration objects, API responses, or user-generated data. Any string can be used as a key, and TypeScript enforces that each value must match the specified type.

Syntax of Index Signatures

The syntax is straightforward: you specify the key type in square brackets followed by the value type. This defines how you can access properties by keys:

interface ResponseCache {
[endpoint: string]: {
data: unknown;
timestamp: number;
};
}

const cache: ResponseCache = {
"/api/users": {
data: [{ id: 1, name: "Alice" }],
timestamp: Date.now()
},
"/api/products": {
data: [{ id: 101, sku: "PROD-101" }],
timestamp: Date.now()
}
};

The key type can be string, number, or symbol, but string is most common since JavaScript object keys are always coerced to strings.

Basic Usage of Index Signatures

Index signatures work well when you don't know all property names in advance but know their types. They're perfect for managing collections of similar items:

interface FeatureFlags {
[flagName: string]: boolean;
}

const appFlags: FeatureFlags = {
enableDarkMode: true,
experimentalFeatures: false,
betaAccess: true
};

// You can add new properties dynamically
appFlags.newFeature = false;

This pattern works well for caching data, managing dynamic configurations, or handling API responses with variable keys.

Types and Constraints

Index Signature Parameter Types

You can specify the type for the keys in your index signature, often string or number, to indicate what type of keys are expected in the object:

interface StatusCodes {
[code: number]: string;
}

const httpStatuses: StatusCodes = {
200: "OK",
404: "Not Found",
500: "Internal Server Error"
};

console.log(httpStatuses[200]); // "OK"

The key type determines how properties can be accessed. Unlike TypeScript objects with fixed properties, index signatures provide flexibility for dynamic keys.

Value Types in Index Signatures

The value type in an index signature dictates what type of values the object's properties can hold. This sets expectations for the data stored in the object:

interface AppConfig {
[setting: string]: string | number | boolean;
}

const config: AppConfig = {
theme: "dark",
maxRetries: 3,
enableLogging: true
};

You can use union types to allow multiple value types. This approach differs from the TypeScript Record<K, T> utility type, which provides a more concise syntax for similar patterns.

Understanding Key Types: String vs Number vs Symbol

How JavaScript Treats Object Keys

JavaScript always converts object keys to strings, which affects how TypeScript's index signatures work. When you use a number as a key, JavaScript coerces it to a string behind the scenes:

const obj = {
0: "first",
1: "second"
};

// These all access the same property
console.log(obj[0]); // "first"
console.log(obj["0"]); // "first"
console.log(obj.0); // Syntax error, but obj["0"] works

String Index Signatures

String index signatures are the most common and accept any string as a key:

interface TranslationDict {
[key: string]: string;
}

const translations: TranslationDict = {
"greeting.hello": "Hello",
"greeting.goodbye": "Goodbye",
"error.notFound": "Not Found"
};

Because JavaScript converts all keys to strings, a string index signature will also match numeric keys.

Number Index Signatures

Number index signatures are useful for array-like objects or tuples where you want to enforce numeric indexing:

interface TimeSeries {
[timestamp: number]: number;
}

const temperatures: TimeSeries = {
1609459200: 72,
1609545600: 75,
1609632000: 68
};

Important: If you have both string and number index signatures in the same type, the number-indexed values must be assignable to the string-indexed type. This reflects JavaScript's reality where obj[0] and obj["0"] access the same property.

Symbol Index Signatures

Symbol keys provide truly unique property keys that won't conflict with string keys:

const uniqueId = Symbol("id");
const metadata = Symbol("metadata");

interface TrackedObject {
[key: symbol]: unknown;
name: string;
}

const user: TrackedObject = {
name: "Alice",
[uniqueId]: "usr_123",
[metadata]: { createdAt: new Date() }
};

Symbol keys are less common but useful when you need guaranteed unique identifiers that won't clash with string properties.

Index Signatures vs Record Type

When to Use Each Approach

Both index signatures and the Record utility type let you define objects with dynamic keys, but they serve different purposes:

// Index signature - truly dynamic keys
interface DynamicConfig {
[key: string]: string | number;
}

// Record - constrained to specific keys
type KnownConfig = Record<"apiUrl" | "timeout" | "retries", string | number>;

const dynamic: DynamicConfig = {
anything: "goes",
here: 123
};

const known: KnownConfig = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3
// TypeScript error if you add other keys
};

Decision Matrix

Use index signatures when:

  • You don't know what keys will exist at compile time
  • Keys come from user input, API responses, or runtime data
  • You need maximum flexibility for property names

Use Record when:

  • You know the exact set of possible keys (even if it's a union)
  • You want TypeScript to catch typos in key names
  • You need stricter type safety and better autocomplete
// Good use of index signature
interface CacheStore {
[cacheKey: string]: {
data: unknown;
expiresAt: number;
};
}

// Good use of Record
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type RouteHandlers = Record<HttpMethod, (req: Request) => Response>;

The Record type provides better IDE support and catches more errors at compile time, but index signatures give you the flexibility to handle truly dynamic data.

Handling Unknown Property Keys with Index Signatures

The Undefined Gotcha

Here's a critical pitfall: when you access a property using an index signature, TypeScript assumes the property exists and returns the value type. But at runtime, accessing a nonexistent property returns undefined:

interface Settings {
[key: string]: string;
}

const settings: Settings = {
theme: "dark"
};

const language = settings.language; // TypeScript thinks this is string
console.log(language); // undefined at runtime!
console.log(language.toUpperCase()); // Runtime error: Cannot read property 'toUpperCase' of undefined

This is one of the most common sources of bugs with index signatures. The solution is to explicitly include undefined in your value type:

interface SafeSettings {
[key: string]: string | undefined;
}

const safeSettings: SafeSettings = {
theme: "dark"
};

const language = safeSettings.language; // TypeScript knows this might be undefined
if (language) {
console.log(language.toUpperCase()); // Safe - checked first
}

Using Optional Chaining to Access Properties

Optional chaining provides a cleaner way to handle potentially missing properties:

interface UserPreferences {
[key: string]: string | undefined;
}

const prefs: UserPreferences = {
theme: "dark"
};

console.log(prefs.theme?.toUpperCase()); // "DARK"
console.log(prefs.language?.toUpperCase()); // undefined (no error)

This approach prevents runtime errors when accessing undefined properties, similar to how you'd handle optional values in regular TypeScript interfaces.

Implementing Error Handling for Unknown Property Keys

You can create helper functions that safely access properties and provide fallback values:

function getSetting(key: string, settings: Settings): string | undefined {
return settings[key];
}

// With default values using nullish coalescing
function getSettingWithDefault(
key: string,
settings: Settings,
defaultValue: string
): string {
return settings[key] ?? defaultValue;
}

console.log(getSetting("theme", settings)); // "dark"
console.log(getSetting("language", settings)); // undefined
console.log(getSettingWithDefault("language", settings, "en")); // "en"

This pattern works well when integrating with Convex's TypeScript best practices for handling dynamic data structures.

Creating Dictionaries with Index Signatures

Defining a Dictionary-like Object with Index Signatures

Index signatures make it easy to create dictionary-like objects where you can dynamically add key-value pairs:

interface ErrorMessages {
[errorCode: string]: string;
}

const errors: ErrorMessages = {};
errors["AUTH_FAILED"] = "Invalid credentials";
errors["RATE_LIMITED"] = "Too many requests";
errors["SERVER_ERROR"] = "Internal server error";

console.log(errors["AUTH_FAILED"]); // "Invalid credentials"

This pattern is commonly used for translation dictionaries, lookup tables, and cache implementations. When building Convex applications, you might use this approach to store dynamic configuration values or response data from API calls.

For more complex scenarios, you can combine index signatures with other TypeScript types to create specialized structures:

interface GameState {
scores: {
[playerName: string]: number;
};
lastUpdated: Date;
gameMode: "solo" | "multiplayer";
}

const currentGame: GameState = {
scores: {
"Alice": 1250,
"Bob": 980,
"Charlie": 1100
},
lastUpdated: new Date(),
gameMode: "multiplayer"
};

// Add new player dynamically
currentGame.scores["Diana"] = 500;

Readonly Index Signatures

Creating Immutable Objects

You can combine readonly with index signatures to create objects that can't be modified after creation:

interface ReadonlyConfig {
readonly [key: string]: string;
}

const apiConfig: ReadonlyConfig = {
endpoint: "https://api.example.com",
version: "v2"
};

// TypeScript error: Index signature in type 'ReadonlyConfig' only permits reading
// apiConfig.endpoint = "https://different-api.com";

This pattern is useful for configuration objects that should remain constant throughout your application's lifetime.

Combining Readonly with Utility Types

You can use the Readonly utility type along with Record for more concise immutable dictionaries:

type ImmutableCache = Readonly<Record<string, {
data: unknown;
timestamp: number;
}>>;

const cache: ImmutableCache = {
"/users": {
data: [{ id: 1 }],
timestamp: Date.now()
}
};

// Error: Cannot assign to 'data' because it is a read-only property
// cache["/users"].data = [];

Remember that readonly only prevents reassignment of properties. If the value is an object or array, its internal contents can still be modified unless you use deep readonly patterns.

Common Pitfalls and Best Practices

Balancing Flexibility with Type Safety

Index signatures provide flexibility, but they can reduce type safety if used carelessly. Define your index signatures as narrowly as possible:

// Too permissive - avoid this
interface TooLoose {
[key: string]: any; // Loses all type safety
}

// Better - specific types
interface BetterConfig {
[option: string]: boolean | string | number;
}

// Best - combine with known properties
interface BestConfig {
[option: string]: boolean | string | number;
// Explicitly type known properties
enableLogs: boolean;
port: number;
apiUrl: string;
}

When you need more precise typing, consider using TypeScript Map types with the TypeScript keyof operator to transform existing types.

Mixing Explicit Properties with Index Signatures

When you combine explicit properties with an index signature, the explicit property types must be assignable to the index signature's value type:

// Error: Property 'timeout' of type 'number' is not assignable
// to string index type 'string'
interface BrokenConfig {
[key: string]: string;
timeout: number; // Type error!
}

// Fix: Make the index signature accept both types
interface WorkingConfig {
[key: string]: string | number;
timeout: number; // Now works
apiUrl: string;
}

This constraint exists because in JavaScript, you could access config["timeout"], and TypeScript needs to ensure the type is consistent with the index signature.

The toString() Coercion Trap

JavaScript automatically calls .toString() on any object used as a key. This can lead to unexpected behavior:

interface ObjectMap {
[key: string]: number;
}

const map: ObjectMap = {};
const keyObj = { id: 1 };

map[keyObj] = 100; // JavaScript converts keyObj to "[object Object]"
console.log(map["[object Object]"]); // 100

// All object keys become the same string!
const anotherKey = { id: 2 };
map[anotherKey] = 200; // Overwrites the previous value
console.log(map[keyObj]); // 200 (not 100!)

If you need object keys, use a Map instead:

const properMap = new Map<object, number>();
properMap.set(keyObj, 100);
properMap.set(anotherKey, 200);
console.log(properMap.get(keyObj)); // 100 (correct)

Error Handling with Unknown Property Keys

Build defensive code that gracefully handles missing or unexpected properties:

interface UserConfig {
[key: string]: string | number | undefined;
}

function getConfigValue(config: UserConfig, key: string): string | number {
const value = config[key];
if (value === undefined) {
throw new Error(`Configuration key "${key}" not found`);
}
return value;
}

// Better approach with type narrowing and defaults
function safeGetConfig<T extends string | number>(
config: UserConfig,
key: string,
defaultValue: T
): T {
const value = config[key];
return (value !== undefined && typeof value === typeof defaultValue)
? value as T
: defaultValue;
}

These patterns align with Convex's API server modules which often deal with dynamic data structures.

Integrating Index Signatures with Strict Type Checking

Using Type Guards Instead of Assertions

Instead of using as to assert types, create type guards for safer runtime checks:

interface Config {
[key: string]: boolean | string | number;
}

const config: Config = {
enabled: true,
port: 8080,
timeout: "30s"
};

// Avoid this - type assertion bypasses safety
const timeout = config.timeout as string;

// Better - type guard with runtime check
function isString(value: unknown): value is string {
return typeof value === "string";
}

function getStringValue(config: Config, key: string): string | null {
const value = config[key];
return isString(value) ? value : null;
}

const safeTimeout = getStringValue(config, "timeout"); // "30s" or null

Type guards provide actual runtime safety, not just compile-time assumptions. This matters when dealing with data from external sources like APIs.

Advanced Type-Safe Getters

For complex scenarios, you can create generic getters that validate types at runtime:

type ConfigValue = string | number | boolean;

function getTypedValue<T extends ConfigValue>(
config: Config,
key: string,
validator: (val: unknown) => val is T
): T | undefined {
const value = config[key];
return validator(value) ? value : undefined;
}

// Usage with custom validators
const isNumber = (val: unknown): val is number => typeof val === "number";
const port = getTypedValue(config, "port", isNumber); // number | undefined

When building applications with Convex filters, this approach provides safer type narrowing for dynamic data structures.

Troubleshooting Errors and Common Challenges

When working with index signatures, you'll encounter common type errors. Here's how to diagnose and fix them:

interface StrictNumbers {
[key: string]: number;
}

// Error: Type 'string' is not assignable to type 'number'
const values: StrictNumbers = {
score: 100,
level: 2
// name: "player" // This causes an error
};

// Solution 1: Widen the value type
interface FlexibleData {
[key: string]: number | string;
}

// Solution 2: Separate concerns with explicit properties
interface BetterData {
[key: string]: number;
// Use optional explicit properties for different types
}

// Solution 3: Use two separate interfaces
interface Scores {
[key: string]: number;
}

interface PlayerInfo {
name: string;
scores: Scores;
}

Validating Data Structures at Runtime

When receiving data from external sources, validate it before trusting the index signature types:

// Runtime validator for objects with number values
function hasNumberValues(data: unknown): data is { [key: string]: number } {
if (typeof data !== "object" || data === null) {
return false;
}

return Object.values(data).every(value => typeof value === "number");
}

// Use the validator
function processScoreData(data: unknown) {
if (!hasNumberValues(data)) {
throw new Error("Invalid score data structure");
}

// Safe to use as an object with number values
Object.entries(data).forEach(([player, score]) => {
console.log(`${player}: ${score}`);
});
}

For complex debugging scenarios with nested structures, combine index signatures with TypeScript array methods to inspect data systematically.

The "Index signature is missing" Error

You might see this error when passing objects between different types:

interface Numbered {
[key: string]: number;
}

interface SpecificData {
count: number;
total: number;
}

const specific: SpecificData = { count: 5, total: 10 };

// Error: Type 'SpecificData' is not assignable to type 'Numbered'
// Index signature for type 'string' is missing in type 'SpecificData'
// const indexed: Numbered = specific;

// Fix 1: Use type assertion if you're certain
const indexed1: Numbered = specific as Numbered;

// Fix 2: Create a new object that explicitly matches
const indexed2: Numbered = { ...specific };

// Fix 3: Use a generic constraint
function processNumbered<T extends Numbered>(data: T) {
// Works with both Numbered and SpecificData
}

Final Thoughts about TypeScript Index Signatures

Index signatures give you the tools to define objects with dynamically named properties while maintaining type safety. They're essential for working with real-world data that doesn't fit into rigid type structures, like API responses, configuration objects, and user-generated content.

When working with index signatures, remember to:

  • Include undefined in your value types to reflect JavaScript's reality
  • Choose Record over index signatures when you know the possible keys upfront
  • Use type guards for runtime validation instead of type assertions
  • Combine explicit properties with index signatures for hybrid structures
  • Consider alternatives like Map when you need non-string keys or better key uniqueness

By understanding these patterns and avoiding common pitfalls, you can build flexible data structures without sacrificing the type safety that makes TypeScript valuable.