TypeScript Index Signatures: Dynamic Property Types

When you're working with objects whose property names aren't known until runtime—API responses, user-generated data, configuration files—TypeScript needs a way to type-check these dynamic structures....

Key Insights

  • Index signatures let you define types for objects with dynamic property names, but they force all properties (including known ones) to conform to the signature’s value type
  • String and number index signatures behave differently—number keys are actually converted to strings at runtime, and string index signatures apply to both
  • Modern TypeScript offers better alternatives like Record<K, V>, mapped types, and template literal types that provide stronger type safety than basic index signatures

Introduction to Index Signatures

When you’re working with objects whose property names aren’t known until runtime—API responses, user-generated data, configuration files—TypeScript needs a way to type-check these dynamic structures. Index signatures solve this problem by defining types for properties that don’t have explicit names at compile time.

The basic syntax uses bracket notation inside an interface or type definition. Instead of naming a specific property, you specify the type of the key and the type of the value:

interface ScoreTracker {
  [playerName: string]: number;
}

const gameScores: ScoreTracker = {
  alice: 150,
  bob: 200,
  charlie: 175
};

// TypeScript knows this returns a number (or undefined)
const aliceScore = gameScores.alice;
const dynamicScore = gameScores["some-player"]; // Also number | undefined

This tells TypeScript: “This object can have any string property, and all those properties will have number values.” You can add or access properties dynamically, and TypeScript will enforce that the values match the signature.

Index Signature Syntax and Types

TypeScript supports three types of index signatures: string, number, and symbol. Each serves different use cases, and they interact in specific ways.

String Index Signatures are the most common. They apply to any string property name:

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

const config: AppConfig = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
  debugMode: true
};

Number Index Signatures are used for array-like structures:

interface CustomArray {
  [index: number]: string;
}

const items: CustomArray = {
  0: "first",
  1: "second",
  2: "third"
};

// Access works like an array
console.log(items[0]); // "first"

Here’s a critical gotcha: JavaScript converts number keys to strings at runtime. TypeScript enforces that if you have both string and number index signatures, the number signature’s value type must be assignable to the string signature’s value type:

interface MixedIndex {
  [key: string]: string | number;
  [index: number]: number; // Must be assignable to string | number
}

You can combine index signatures with known properties, but the known properties must conform to the index signature type:

interface UserCache {
  [userId: string]: User;
  currentUser: User; // OK - User matches the index signature
  count: number;     // ERROR - number doesn't match User
}

// Fix with union types
interface BetterUserCache {
  [key: string]: User | number;
  currentUser: User;
  count: number;
}

Common Use Cases and Patterns

Index signatures shine when dealing with truly dynamic data. Here are the most practical scenarios:

API Response Handling: When you receive JSON with unknown or variable fields:

interface ApiResponse<T> {
  data: T;
  [metadata: string]: unknown; // Allow any additional fields
}

function processResponse<T>(response: ApiResponse<T>): T {
  // Access known properties with type safety
  const data = response.data;
  
  // Access unknown properties (typed as unknown)
  const requestId = response.requestId;
  
  return data;
}

Dynamic Form Data: User-submitted forms with variable field names:

interface FormData {
  [fieldName: string]: string | number | boolean;
}

function validateForm(data: FormData): boolean {
  for (const key in data) {
    const value = data[key];
    
    if (typeof value === 'string' && value.trim() === '') {
      console.error(`Field ${key} is empty`);
      return false;
    }
  }
  return true;
}

const userForm: FormData = {
  username: "john_doe",
  age: 30,
  newsletter: true
};

Localization Dictionaries: Translation keys that vary by language:

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

interface I18n {
  [locale: string]: Translations;
}

const translations: I18n = {
  en: {
    greeting: "Hello",
    farewell: "Goodbye"
  },
  es: {
    greeting: "Hola",
    farewell: "Adiós"
  }
};

function translate(locale: string, key: string): string {
  return translations[locale]?.[key] ?? key;
}

Limitations and Gotchas

Index signatures come with significant constraints that can surprise developers. Understanding these limitations helps you avoid frustrating compiler errors.

All Properties Must Conform: This is the biggest gotcha. Once you add an index signature, every property—including explicitly named ones—must match that signature’s value type:

interface ProblematicConfig {
  [key: string]: string;
  timeout: number; // ERROR: number not assignable to string
  retries: number; // ERROR: number not assignable to string
}

The solution is union types, but this weakens type safety:

interface FixedConfig {
  [key: string]: string | number;
  timeout: number;  // OK
  retries: number;  // OK
  apiUrl: string;   // OK
}

// But now you lose specificity
const config: FixedConfig = { timeout: "not a number" }; // No error!

Optional Properties and Undefined: Index signatures don’t automatically make properties optional. The signature says properties exist, but accessing them might return undefined:

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

const lookup: Lookup = { a: 1, b: 2 };
const value = lookup.c; // Type is number, runtime is undefined!

// Enable strictNullChecks to catch this
// Or explicitly include undefined
interface SaferLookup {
  [key: string]: number | undefined;
}

The Any Escape Hatch: It’s tempting to use any as the value type, but this defeats TypeScript’s purpose:

// Don't do this
interface LazyConfig {
  [key: string]: any;
}

// Use unknown instead for type-safe dynamic access
interface BetterConfig {
  [key: string]: unknown;
}

function getValue(config: BetterConfig, key: string): string {
  const value = config[key];
  // Must narrow the type before using it
  if (typeof value === 'string') {
    return value;
  }
  throw new Error(`Expected string for ${key}`);
}

Advanced Patterns with Mapped Types and Utility Types

Modern TypeScript offers more powerful alternatives to basic index signatures. These patterns provide better type safety and more precise control.

Record Utility Type: The Record<K, V> utility type is cleaner and more explicit than index signatures:

// Instead of this
interface OldWay {
  [key: string]: number;
}

// Use this
type NewWay = Record<string, number>;

// Record is especially useful with literal unions
type Status = 'pending' | 'approved' | 'rejected';
type StatusCounts = Record<Status, number>;

const counts: StatusCounts = {
  pending: 5,
  approved: 12,
  rejected: 3
  // TypeScript ensures all statuses are present
};

Mapped Types with keyof: For transforming existing types, mapped types offer precision that index signatures can’t match:

interface User {
  id: number;
  name: string;
  email: string;
}

// Create a type with all User properties as optional booleans
type UserFlags = {
  [K in keyof User]: boolean;
};
// Results in: { id: boolean; name: boolean; email: boolean; }

// Partial update type that maintains original value types
type PartialUpdate<T> = {
  [K in keyof T]?: T[K];
};

const userUpdate: PartialUpdate<User> = {
  name: "New Name"
  // id and email are optional, but if present, must match types
};

Template Literal Types: For pattern-based keys, template literals provide compile-time validation:

type EventName = 'click' | 'focus' | 'blur';
type EventHandler<T extends string> = `on${Capitalize<T>}`;

type EventHandlers = {
  [K in EventName as EventHandler<K>]: (event: Event) => void;
};
// Results in: { onClick: ..., onFocus: ..., onBlur: ... }

const handlers: EventHandlers = {
  onClick: (e) => console.log('clicked'),
  onFocus: (e) => console.log('focused'),
  onBlur: (e) => console.log('blurred')
};

Best Practices and Alternatives

Index signatures are powerful but often overused. Here’s when to use them and when to choose alternatives.

Prefer Specific Types: If you know the possible keys, define them explicitly:

// Instead of this
interface WeakConfig {
  [key: string]: string | number;
}

// Do this
interface StrongConfig {
  apiUrl: string;
  timeout: number;
  retryAttempts: number;
}

Use Map for Truly Dynamic Data: When you need runtime flexibility with type safety, JavaScript’s Map is often better:

// Index signature loses type information
const cache: { [id: string]: User } = {};

// Map preserves it and offers better methods
const userCache = new Map<string, User>();
userCache.set('user1', { id: 1, name: 'Alice' });
userCache.has('user1'); // true
userCache.delete('user1');

// Maps also support non-string keys properly
const objectKeyMap = new Map<object, string>();

Refactor Toward Precision: As your codebase evolves, replace index signatures with more specific types:

// Start: Flexible but weak
interface ApiResponseV1 {
  [key: string]: unknown;
}

// Evolve: Define known structure
interface ApiResponseV2 {
  data: unknown;
  metadata: {
    timestamp: number;
    requestId: string;
  };
  [key: string]: unknown; // Keep escape hatch if needed
}

// Mature: Fully typed
interface ApiResponseV3<T> {
  data: T;
  metadata: {
    timestamp: number;
    requestId: string;
  };
}

Index signatures are a necessary tool for handling dynamic JavaScript objects in TypeScript. Use them when you genuinely don’t know property names at compile time, but always look for opportunities to add more specific types as your understanding of the data structure grows. The strongest TypeScript code uses index signatures sparingly, preferring explicit interfaces, Record types, or mapped types that give you both flexibility and safety.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.