+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Part 46 of 354

πŸ”‘ Index Signatures: Dynamic Property Access

Master index signatures in TypeScript to handle dynamic properties and create flexible, type-safe objects πŸš€

πŸš€Intermediate
20 min read

Prerequisites

  • Understanding of interfaces and types πŸ“
  • Basic TypeScript syntax πŸ”
  • Object manipulation knowledge πŸ’»

What you'll learn

  • Understand index signature syntax and usage 🎯
  • Handle dynamic property names safely πŸ—οΈ
  • Combine index signatures with known properties πŸ›‘οΈ
  • Apply best practices for flexible APIs ✨

🎯 Introduction

Welcome to the dynamic world of index signatures! πŸŽ‰ In this guide, we’ll explore how TypeScript’s index signatures enable you to work with objects that have dynamic property names while maintaining type safety.

You’ll discover how index signatures are like flexible containers πŸ“¦ - they can hold any number of properties with names you don’t know in advance. Whether you’re handling API responses 🌐, building configuration objects βš™οΈ, or creating dictionaries πŸ“š, understanding index signatures is essential for working with dynamic data structures in TypeScript.

By the end of this tutorial, you’ll be confidently creating type-safe objects that can adapt to any property name! Let’s unlock the power of dynamic properties! πŸŠβ€β™‚οΈ

πŸ“š Understanding Index Signatures

πŸ€” What are Index Signatures?

Index signatures allow you to define the types for properties when you don’t know all the property names ahead of time, but you know the shape of the values. It’s like saying β€œI don’t know what keys this object will have, but I know all values will be of this type.”

Think of index signatures like:

  • πŸ“š Dictionary: Any word (key) maps to a definition (value)
  • πŸ—ΊοΈ Map: Any location name maps to coordinates
  • πŸͺ Store inventory: Any product ID maps to product details
  • 🎨 Color palette: Any color name maps to a hex value

πŸ’‘ Why Use Index Signatures?

Here’s why developers love index signatures:

  1. Dynamic Data 🌊: Handle data with unknown property names
  2. API Flexibility 🌐: Work with varying response structures
  3. Configuration Objects βš™οΈ: Create extensible settings
  4. Type Safety πŸ›‘οΈ: Maintain types even with dynamic keys

Real-world example: User preferences 🎨 - users can have any number of custom settings, but all values follow a consistent type structure!

πŸ”§ Basic Syntax and Usage

πŸ“ Simple Index Signatures

Let’s start with the fundamentals:

// πŸ“š Basic string index signature
interface StringDictionary {
  [key: string]: string;
}

const colors: StringDictionary = {
  red: '#FF0000',
  green: '#00FF00',
  blue: '#0000FF',
  // Can add any string key with string value
  purple: '#800080',
  'dark-gray': '#333333'
};

// βœ… All these work
colors.yellow = '#FFFF00';
colors['light-blue'] = '#ADD8E6';
const randomColor = colors['red'];

// ❌ This would error - value must be string
// colors.white = 255; // Error: Type 'number' is not assignable to type 'string'

// πŸ”’ Number index signature
interface NumberArray {
  [index: number]: string;
}

const monthNames: NumberArray = {
  0: 'January',
  1: 'February',
  2: 'March',
  // ... and so on
};

// βœ… Access like an array
console.log(monthNames[0]); // 'January'
monthNames[11] = 'December';

// 🎯 Mixed known and index properties
interface UserPreferences {
  // Known properties
  theme: 'light' | 'dark';
  language: string;
  
  // Index signature for custom preferences
  [preference: string]: string | boolean | number;
}

const prefs: UserPreferences = {
  theme: 'dark',
  language: 'en',
  // Custom preferences
  fontSize: 16,
  autoSave: true,
  customColor: '#007bff',
  'sidebar.width': 250
};

// πŸ—‚οΈ Nested index signatures
interface NestedConfig {
  [section: string]: {
    [setting: string]: string | number | boolean;
  };
}

const appConfig: NestedConfig = {
  display: {
    theme: 'dark',
    fontSize: 14,
    showGrid: true
  },
  editor: {
    tabSize: 2,
    wordWrap: true,
    autoIndent: true
  },
  network: {
    timeout: 30000,
    retryAttempts: 3,
    useProxy: false
  }
};

// Access nested properties
console.log(appConfig.display.theme); // 'dark'
appConfig.editor.lineNumbers = true; // Add new property

πŸ—οΈ Advanced Index Signature Patterns

Working with more complex scenarios:

// πŸ”§ Combining string and number index signatures
interface StringAndNumberIndex {
  [key: string]: string | number;
  [index: number]: string; // Must be compatible with string indexer
}

// Note: number index type must be assignable to string index type
const mixed: StringAndNumberIndex = {
  0: 'first',
  1: 'second',
  name: 'Mixed Collection',
  count: 2
};

// 🎨 Type-safe event handlers
interface EventHandlers {
  // Specific known events
  onClick?: (event: MouseEvent) => void;
  onKeyPress?: (event: KeyboardEvent) => void;
  
  // Index signature for custom events
  [eventName: `on${string}`]: ((event: any) => void) | undefined;
}

const handlers: EventHandlers = {
  onClick: (e) => console.log('Clicked!', e.clientX, e.clientY),
  onCustomEvent: (e) => console.log('Custom!', e),
  onUserAction: (e) => console.log('User action!', e),
  // ❌ This would error - doesn't match pattern
  // customHandler: () => {} // Error: Property 'customHandler' is incompatible
};

// πŸ“Š Generic index signatures
interface GenericDictionary<T> {
  [key: string]: T;
}

interface Product {
  name: string;
  price: number;
  inStock: boolean;
}

const inventory: GenericDictionary<Product> = {
  'LAPTOP-001': {
    name: 'Gaming Laptop',
    price: 1299.99,
    inStock: true
  },
  'MOUSE-002': {
    name: 'Wireless Mouse',
    price: 49.99,
    inStock: false
  }
};

// πŸ›‘οΈ Readonly index signatures
interface ReadonlyDictionary {
  readonly [key: string]: string;
}

const constants: ReadonlyDictionary = {
  PI: '3.14159',
  E: '2.71828',
  GOLDEN_RATIO: '1.61803'
};

// ❌ Cannot modify
// constants.PI = '3.14'; // Error: Index signature in type 'ReadonlyDictionary' only permits reading

// πŸ” Partial index signatures with utility types
type PartialRecord<K extends string | number | symbol, T> = {
  [P in K]?: T;
};

type ColorScheme = PartialRecord<'primary' | 'secondary' | 'accent', string>;

const theme: ColorScheme = {
  primary: '#007bff',
  // secondary and accent are optional
};

🎨 Real-World Applications

🌐 API Response Handling

Working with dynamic API responses:

// πŸ”„ API response wrapper
interface ApiResponse<T> {
  data: T;
  meta: {
    timestamp: string;
    version: string;
    [key: string]: any; // Additional metadata
  };
  errors?: {
    [field: string]: string[]; // Field-specific errors
  };
}

// πŸ“Š Analytics data with dynamic metrics
interface AnalyticsData {
  userId: string;
  sessionId: string;
  timestamp: Date;
  
  // Dynamic metrics
  metrics: {
    [metricName: string]: number;
  };
  
  // Dynamic properties
  properties: {
    [property: string]: string | number | boolean;
  };
}

class AnalyticsTracker {
  private data: AnalyticsData[] = [];
  
  track(
    userId: string,
    event: string,
    metrics: Record<string, number>,
    properties: Record<string, string | number | boolean>
  ): void {
    const analyticsData: AnalyticsData = {
      userId,
      sessionId: this.generateSessionId(),
      timestamp: new Date(),
      metrics: {
        ...metrics,
        [`${event}_count`]: 1,
        [`${event}_timestamp`]: Date.now()
      },
      properties: {
        event,
        ...properties,
        browser: this.getBrowser(),
        os: this.getOS()
      }
    };
    
    this.data.push(analyticsData);
    console.log(`πŸ“Š Tracked ${event}:`, analyticsData);
  }
  
  getMetricSum(metricName: string): number {
    return this.data.reduce((sum, item) => {
      return sum + (item.metrics[metricName] || 0);
    }, 0);
  }
  
  getUniqueUsers(): string[] {
    const users = new Set(this.data.map(d => d.userId));
    return Array.from(users);
  }
  
  private generateSessionId(): string {
    return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
  }
  
  private getBrowser(): string {
    return 'Chrome'; // Simplified
  }
  
  private getOS(): string {
    return 'Windows'; // Simplified
  }
}

// πŸͺ E-commerce cart with dynamic products
interface ShoppingCart {
  userId: string;
  items: {
    [productId: string]: {
      quantity: number;
      price: number;
      name: string;
      attributes?: {
        [key: string]: string;
      };
    };
  };
  metadata: {
    createdAt: Date;
    updatedAt: Date;
    [key: string]: any;
  };
}

class CartManager {
  private carts: Map<string, ShoppingCart> = new Map();
  
  createCart(userId: string): ShoppingCart {
    const cart: ShoppingCart = {
      userId,
      items: {},
      metadata: {
        createdAt: new Date(),
        updatedAt: new Date(),
        source: 'web',
        currency: 'USD'
      }
    };
    
    this.carts.set(userId, cart);
    return cart;
  }
  
  addItem(
    userId: string,
    productId: string,
    product: {
      name: string;
      price: number;
      quantity: number;
      attributes?: Record<string, string>;
    }
  ): void {
    const cart = this.carts.get(userId) || this.createCart(userId);
    
    if (cart.items[productId]) {
      cart.items[productId].quantity += product.quantity;
    } else {
      cart.items[productId] = {
        quantity: product.quantity,
        price: product.price,
        name: product.name,
        attributes: product.attributes
      };
    }
    
    cart.metadata.updatedAt = new Date();
    cart.metadata.itemCount = Object.keys(cart.items).length;
    cart.metadata.totalItems = Object.values(cart.items)
      .reduce((sum, item) => sum + item.quantity, 0);
  }
  
  getCartTotal(userId: string): number {
    const cart = this.carts.get(userId);
    if (!cart) return 0;
    
    return Object.values(cart.items).reduce((total, item) => {
      return total + (item.price * item.quantity);
    }, 0);
  }
  
  applyDiscount(userId: string, discountCode: string, discount: number): void {
    const cart = this.carts.get(userId);
    if (!cart) return;
    
    cart.metadata[`discount_${discountCode}`] = discount;
    cart.metadata.totalDiscount = Object.keys(cart.metadata)
      .filter(key => key.startsWith('discount_'))
      .reduce((sum, key) => sum + cart.metadata[key], 0);
  }
}

πŸ—οΈ Configuration Systems

Building flexible configuration systems:

// βš™οΈ Application configuration with index signatures
interface AppConfig {
  // Core settings (required)
  appName: string;
  version: string;
  environment: 'development' | 'staging' | 'production';
  
  // Feature flags
  features: {
    [featureName: string]: boolean | {
      enabled: boolean;
      config?: Record<string, any>;
    };
  };
  
  // Module configurations
  modules: {
    [moduleName: string]: {
      enabled: boolean;
      settings: Record<string, any>;
    };
  };
  
  // Custom settings
  [key: string]: any;
}

class ConfigManager {
  private config: AppConfig;
  private validators: Map<string, (value: any) => boolean> = new Map();
  
  constructor(initialConfig: AppConfig) {
    this.config = this.deepClone(initialConfig);
    this.setupDefaultValidators();
  }
  
  private setupDefaultValidators(): void {
    // Add validators for known config paths
    this.addValidator('port', (value) => {
      return typeof value === 'number' && value > 0 && value < 65536;
    });
    
    this.addValidator('timeout', (value) => {
      return typeof value === 'number' && value > 0;
    });
    
    this.addValidator('apiUrl', (value) => {
      try {
        new URL(value);
        return true;
      } catch {
        return false;
      }
    });
  }
  
  get<T = any>(path: string): T | undefined {
    const parts = path.split('.');
    let current: any = this.config;
    
    for (const part of parts) {
      if (current && typeof current === 'object' && part in current) {
        current = current[part];
      } else {
        return undefined;
      }
    }
    
    return current as T;
  }
  
  set(path: string, value: any): boolean {
    // Validate if validator exists
    if (this.validators.has(path)) {
      const validator = this.validators.get(path)!;
      if (!validator(value)) {
        console.error(`❌ Invalid value for ${path}`);
        return false;
      }
    }
    
    const parts = path.split('.');
    const lastPart = parts.pop()!;
    let current: any = this.config;
    
    // Navigate to the parent object
    for (const part of parts) {
      if (!(part in current)) {
        current[part] = {};
      }
      current = current[part];
    }
    
    // Set the value
    current[lastPart] = value;
    console.log(`βœ… Set ${path} = ${JSON.stringify(value)}`);
    return true;
  }
  
  addValidator(path: string, validator: (value: any) => boolean): void {
    this.validators.set(path, validator);
  }
  
  isFeatureEnabled(feature: string): boolean {
    const featureConfig = this.config.features[feature];
    
    if (typeof featureConfig === 'boolean') {
      return featureConfig;
    }
    
    if (typeof featureConfig === 'object' && featureConfig !== null) {
      return featureConfig.enabled;
    }
    
    return false;
  }
  
  getFeatureConfig(feature: string): Record<string, any> | undefined {
    const featureConfig = this.config.features[feature];
    
    if (typeof featureConfig === 'object' && featureConfig !== null && 'config' in featureConfig) {
      return featureConfig.config;
    }
    
    return undefined;
  }
  
  private deepClone<T>(obj: T): T {
    return JSON.parse(JSON.stringify(obj));
  }
  
  export(): string {
    return JSON.stringify(this.config, null, 2);
  }
}

// 🎨 Theme system with dynamic properties
interface ThemeConfig {
  name: string;
  colors: {
    // Known color keys
    primary: string;
    secondary: string;
    background: string;
    text: string;
    
    // Dynamic color keys
    [colorName: string]: string;
  };
  
  spacing: {
    // Known spacing keys
    small: number;
    medium: number;
    large: number;
    
    // Dynamic spacing keys
    [size: string]: number;
  };
  
  // Component-specific styles
  components: {
    [componentName: string]: {
      [property: string]: string | number;
    };
  };
}

class ThemeManager {
  private themes: Map<string, ThemeConfig> = new Map();
  private activeTheme: string = 'default';
  
  registerTheme(theme: ThemeConfig): void {
    this.themes.set(theme.name, theme);
    console.log(`🎨 Registered theme: ${theme.name}`);
  }
  
  setActiveTheme(name: string): boolean {
    if (!this.themes.has(name)) {
      console.error(`❌ Theme '${name}' not found`);
      return false;
    }
    
    this.activeTheme = name;
    console.log(`βœ… Active theme: ${name}`);
    return true;
  }
  
  getColor(colorName: string): string | undefined {
    const theme = this.themes.get(this.activeTheme);
    return theme?.colors[colorName];
  }
  
  getSpacing(size: string): number | undefined {
    const theme = this.themes.get(this.activeTheme);
    return theme?.spacing[size];
  }
  
  getComponentStyle(component: string, property: string): string | number | undefined {
    const theme = this.themes.get(this.activeTheme);
    return theme?.components[component]?.[property];
  }
  
  extendTheme(baseName: string, newName: string, extensions: Partial<ThemeConfig>): void {
    const baseTheme = this.themes.get(baseName);
    if (!baseTheme) {
      console.error(`❌ Base theme '${baseName}' not found`);
      return;
    }
    
    const newTheme: ThemeConfig = {
      name: newName,
      colors: { ...baseTheme.colors, ...extensions.colors },
      spacing: { ...baseTheme.spacing, ...extensions.spacing },
      components: this.mergeComponents(baseTheme.components, extensions.components || {})
    };
    
    this.registerTheme(newTheme);
  }
  
  private mergeComponents(
    base: ThemeConfig['components'],
    extensions: ThemeConfig['components']
  ): ThemeConfig['components'] {
    const merged: ThemeConfig['components'] = { ...base };
    
    for (const [component, styles] of Object.entries(extensions)) {
      merged[component] = {
        ...(merged[component] || {}),
        ...styles
      };
    }
    
    return merged;
  }
}

πŸ” Type-Safe Property Access

Advanced patterns for safe property access:

// πŸ›‘οΈ Safe property access utilities
class SafeObject<T extends Record<string, any>> {
  constructor(private obj: T) {}
  
  get<K extends keyof T>(key: K): T[K];
  get(key: string): any;
  get(key: string): any {
    return this.obj[key];
  }
  
  set<K extends keyof T>(key: K, value: T[K]): void;
  set(key: string, value: any): void;
  set(key: string, value: any): void {
    this.obj[key] = value;
  }
  
  has(key: string): key is keyof T {
    return key in this.obj;
  }
  
  keys(): (keyof T)[] {
    return Object.keys(this.obj) as (keyof T)[];
  }
  
  entries(): [keyof T, T[keyof T]][] {
    return Object.entries(this.obj) as [keyof T, T[keyof T]][];
  }
  
  mapValues<U>(fn: (value: T[keyof T], key: keyof T) => U): Record<keyof T, U> {
    const result = {} as Record<keyof T, U>;
    
    for (const [key, value] of this.entries()) {
      result[key] = fn(value, key);
    }
    
    return result;
  }
}

// 🎯 Type-safe translation system
interface TranslationDict {
  [key: string]: string | TranslationDict;
}

class I18n {
  private translations: Map<string, TranslationDict> = new Map();
  private currentLocale: string = 'en';
  
  addTranslations(locale: string, translations: TranslationDict): void {
    this.translations.set(locale, translations);
  }
  
  setLocale(locale: string): boolean {
    if (!this.translations.has(locale)) {
      console.error(`❌ Locale '${locale}' not found`);
      return false;
    }
    
    this.currentLocale = locale;
    return true;
  }
  
  t(key: string, params?: Record<string, string | number>): string {
    const translations = this.translations.get(this.currentLocale);
    if (!translations) return key;
    
    // Navigate nested keys
    const parts = key.split('.');
    let current: string | TranslationDict = translations;
    
    for (const part of parts) {
      if (typeof current === 'object' && part in current) {
        current = current[part];
      } else {
        return key; // Translation not found
      }
    }
    
    if (typeof current !== 'string') {
      return key; // Not a string translation
    }
    
    // Replace parameters
    let result = current;
    if (params) {
      for (const [param, value] of Object.entries(params)) {
        result = result.replace(`{${param}}`, String(value));
      }
    }
    
    return result;
  }
  
  // Get all translations for a namespace
  getNamespace(namespace: string): Record<string, string> {
    const translations = this.translations.get(this.currentLocale);
    if (!translations) return {};
    
    const namespaceTrans = translations[namespace];
    if (typeof namespaceTrans !== 'object') return {};
    
    // Flatten nested translations
    const flattened: Record<string, string> = {};
    
    const flatten = (obj: TranslationDict, prefix: string = ''): void => {
      for (const [key, value] of Object.entries(obj)) {
        const fullKey = prefix ? `${prefix}.${key}` : key;
        
        if (typeof value === 'string') {
          flattened[fullKey] = value;
        } else {
          flatten(value, fullKey);
        }
      }
    };
    
    flatten(namespaceTrans);
    return flattened;
  }
}

// Usage examples
const config = new ConfigManager({
  appName: 'MyApp',
  version: '1.0.0',
  environment: 'development',
  features: {
    darkMode: true,
    betaFeatures: {
      enabled: false,
      config: { allowList: ['user1', 'user2'] }
    }
  },
  modules: {
    auth: {
      enabled: true,
      settings: {
        tokenExpiry: 3600,
        refreshEnabled: true
      }
    }
  }
});

config.set('port', 3000);
config.set('database.host', 'localhost');
config.set('database.port', 5432);

const themeManager = new ThemeManager();
themeManager.registerTheme({
  name: 'default',
  colors: {
    primary: '#007bff',
    secondary: '#6c757d',
    background: '#ffffff',
    text: '#333333',
    success: '#28a745',
    danger: '#dc3545'
  },
  spacing: {
    small: 8,
    medium: 16,
    large: 24,
    xlarge: 32
  },
  components: {
    button: {
      borderRadius: 4,
      padding: '8px 16px',
      fontSize: 14
    },
    card: {
      borderRadius: 8,
      boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
    }
  }
});

const i18n = new I18n();
i18n.addTranslations('en', {
  common: {
    welcome: 'Welcome, {name}!',
    goodbye: 'Goodbye!',
    buttons: {
      save: 'Save',
      cancel: 'Cancel',
      delete: 'Delete'
    }
  },
  errors: {
    notFound: 'Item not found',
    unauthorized: 'You are not authorized',
    validation: {
      required: '{field} is required',
      minLength: '{field} must be at least {min} characters'
    }
  }
});

console.log(i18n.t('common.welcome', { name: 'John' })); // "Welcome, John!"
console.log(i18n.t('errors.validation.required', { field: 'Email' })); // "Email is required"

⚠️ Common Pitfalls and Solutions

😱 Pitfall 1: String vs Number Indexers

// ❌ Problem - conflicting index signatures
interface BadExample {
  [key: string]: string;
  [index: number]: number; // Error! number index type must be assignable to string index type
}

// βœ… Solution 1 - Make number index type compatible
interface GoodExample1 {
  [key: string]: string | number;
  [index: number]: number; // OK - number is assignable to string | number
}

// βœ… Solution 2 - Use separate interfaces
interface StringIndexed {
  [key: string]: string;
}

interface NumberIndexed {
  [index: number]: number;
}

// βœ… Solution 3 - Use Map for true flexibility
class FlexibleContainer {
  private stringMap = new Map<string, string>();
  private numberMap = new Map<number, number>();
  
  setString(key: string, value: string): void {
    this.stringMap.set(key, value);
  }
  
  setNumber(index: number, value: number): void {
    this.numberMap.set(index, value);
  }
  
  getString(key: string): string | undefined {
    return this.stringMap.get(key);
  }
  
  getNumber(index: number): number | undefined {
    return this.numberMap.get(index);
  }
}

🀯 Pitfall 2: Index Signatures and Methods

// ❌ Problem - methods conflict with index signature
interface BadMethodExample {
  [key: string]: string;
  
  // Error! Property 'getValue' of type '() => string' is not assignable to string index type 'string'
  getValue(): string;
}

// βœ… Solution 1 - Include function type in index signature
interface GoodMethodExample1 {
  [key: string]: string | Function;
  getValue(): string;
}

// βœ… Solution 2 - Use intersection types
type StringDict = {
  [key: string]: string;
};

interface Methods {
  getValue(): string;
  setValue(value: string): void;
}

type GoodMethodExample2 = StringDict & Methods;

// βœ… Solution 3 - Separate data and methods
interface DataContainer {
  data: {
    [key: string]: string;
  };
  getValue(key: string): string | undefined;
  setValue(key: string, value: string): void;
}

class Container implements DataContainer {
  data: { [key: string]: string } = {};
  
  getValue(key: string): string | undefined {
    return this.data[key];
  }
  
  setValue(key: string, value: string): void {
    this.data[key] = value;
  }
}

πŸ”„ Pitfall 3: Type Safety with Index Signatures

// ❌ Problem - too permissive index signature
interface TooPermissive {
  [key: string]: any; // Loses all type safety
}

const obj: TooPermissive = {
  name: 'John',
  age: 30,
  invalid: undefined,
  nested: { anything: 'goes' }
};

// No type checking!
obj.typo = 'This should error but doesnt';

// βœ… Solution 1 - Be specific about value types
interface SpecificTypes {
  name: string;
  age: number;
  [key: string]: string | number | undefined;
}

// βœ… Solution 2 - Use template literal patterns
interface BetterPattern {
  name: string;
  age: number;
  
  // Only allow specific patterns
  [key: `custom_${string}`]: string;
  [key: `flag_${string}`]: boolean;
}

const better: BetterPattern = {
  name: 'John',
  age: 30,
  custom_theme: 'dark',
  flag_betaUser: true,
  // custom_invalid: 123, // Error! Must be string
  // invalid: 'test' // Error! Doesn't match any pattern
};

// βœ… Solution 3 - Use branded types for safety
type UserId = string & { __brand: 'UserId' };
type ProductId = string & { __brand: 'ProductId' };

interface SafeStorage {
  users: {
    [id: UserId]: { name: string; email: string };
  };
  products: {
    [id: ProductId]: { name: string; price: number };
  };
}

πŸ› οΈ Best Practices

🎯 Index Signature Guidelines

  1. Be Specific 🎯: Use the most restrictive type possible
  2. Consider Alternatives πŸ€”: Map, Set, or Record might be better
  3. Document Patterns πŸ“: Explain what keys are expected
  4. Validate Input πŸ›‘οΈ: Don’t trust dynamic keys blindly
// 🌟 Well-designed index signature usage
interface WellDesigned {
  // Required, known properties
  id: string;
  type: 'user' | 'admin' | 'guest';
  
  // Optional known properties
  email?: string;
  phone?: string;
  
  // Metadata with specific pattern
  [key: `meta_${string}`]: string | number | boolean;
  
  // Nested configuration
  settings: {
    theme: 'light' | 'dark';
    language: string;
    
    // User preferences
    [key: `pref_${string}`]: any;
  };
}

// πŸ—οΈ Type-safe builder with index signatures
class TypedBuilder<T extends Record<string, any>> {
  private data: Partial<T> = {};
  
  set<K extends keyof T>(key: K, value: T[K]): this {
    this.data[key] = value;
    return this;
  }
  
  setMany(values: Partial<T>): this {
    Object.assign(this.data, values);
    return this;
  }
  
  build(): T {
    // Validate required fields
    const required: (keyof T)[] = ['id', 'type'] as any;
    
    for (const key of required) {
      if (!(key in this.data)) {
        throw new Error(`Missing required field: ${String(key)}`);
      }
    }
    
    return this.data as T;
  }
}

// πŸ” Validated dictionary
class ValidatedDictionary<T> {
  private data: Record<string, T> = {};
  private validators: Array<(key: string, value: T) => boolean> = [];
  
  addValidator(validator: (key: string, value: T) => boolean): void {
    this.validators.push(validator);
  }
  
  set(key: string, value: T): boolean {
    // Run all validators
    for (const validator of this.validators) {
      if (!validator(key, value)) {
        console.error(`❌ Validation failed for key: ${key}`);
        return false;
      }
    }
    
    this.data[key] = value;
    return true;
  }
  
  get(key: string): T | undefined {
    return this.data[key];
  }
  
  has(key: string): boolean {
    return key in this.data;
  }
  
  keys(): string[] {
    return Object.keys(this.data);
  }
  
  values(): T[] {
    return Object.values(this.data);
  }
  
  entries(): [string, T][] {
    return Object.entries(this.data);
  }
  
  clear(): void {
    this.data = {};
  }
}

// Example usage
const userDict = new ValidatedDictionary<{ name: string; role: string }>();

// Add validators
userDict.addValidator((key, value) => {
  return key.startsWith('user_'); // Keys must start with 'user_'
});

userDict.addValidator((key, value) => {
  return value.role === 'admin' || value.role === 'user'; // Valid roles only
});

// Use the dictionary
userDict.set('user_001', { name: 'Alice', role: 'admin' }); // βœ…
userDict.set('invalid_key', { name: 'Bob', role: 'user' }); // ❌ Validation fails
userDict.set('user_002', { name: 'Charlie', role: 'guest' }); // ❌ Invalid role

πŸ§ͺ Hands-On Exercise

🎯 Challenge: Build a Flexible Storage System

Create a type-safe storage system with index signatures:

πŸ“‹ Requirements:

  • βœ… Support different storage namespaces
  • 🎨 Type-safe getters and setters
  • 🎯 Expiration support
  • πŸ“Š Storage analytics
  • πŸ”§ Migration utilities

πŸš€ Bonus Points:

  • Add compression for large values
  • Implement storage quotas
  • Create reactive watchers

πŸ’‘ Solution

πŸ” Click to see solution
// 🎯 Type-safe storage system with index signatures

// Storage value types
interface StorageValue<T = any> {
  data: T;
  metadata: {
    created: Date;
    updated: Date;
    expires?: Date;
    compressed?: boolean;
    size: number;
  };
}

// Storage namespace interface
interface StorageNamespace {
  [key: string]: StorageValue;
}

// Storage schema definition
interface StorageSchema {
  [namespace: string]: {
    [key: string]: any;
  };
}

// Storage events
type StorageEvent = 
  | { type: 'set'; namespace: string; key: string; value: any }
  | { type: 'delete'; namespace: string; key: string }
  | { type: 'clear'; namespace?: string }
  | { type: 'expire'; namespace: string; key: string };

// Main storage class
class TypeSafeStorage<Schema extends StorageSchema> {
  private storage: Map<keyof Schema, Map<string, StorageValue>> = new Map();
  private watchers: Map<string, Set<(event: StorageEvent) => void>> = new Map();
  private expirationTimers: Map<string, NodeJS.Timeout> = new Map();
  private quotas: Map<keyof Schema, number> = new Map();
  
  constructor(private options: {
    defaultTTL?: number;
    compression?: boolean;
    maxSize?: number;
  } = {}) {
    this.initializeNamespaces();
    this.startExpirationChecker();
  }
  
  private initializeNamespaces(): void {
    // Initialize storage maps for type safety
  }
  
  // Type-safe set method
  set<N extends keyof Schema, K extends keyof Schema[N]>(
    namespace: N,
    key: K,
    value: Schema[N][K],
    options?: {
      ttl?: number;
      compress?: boolean;
    }
  ): boolean {
    // Ensure namespace exists
    if (!this.storage.has(namespace)) {
      this.storage.set(namespace, new Map());
    }
    
    const namespaceStorage = this.storage.get(namespace)!;
    const stringKey = String(key);
    
    // Check quota
    if (!this.checkQuota(namespace, value)) {
      console.error(`❌ Storage quota exceeded for namespace: ${String(namespace)}`);
      return false;
    }
    
    // Prepare storage value
    const storageValue: StorageValue<Schema[N][K]> = {
      data: value,
      metadata: {
        created: new Date(),
        updated: new Date(),
        size: this.calculateSize(value),
        compressed: options?.compress ?? this.options.compression
      }
    };
    
    // Set expiration if provided
    if (options?.ttl || this.options.defaultTTL) {
      const ttl = options?.ttl ?? this.options.defaultTTL!;
      storageValue.metadata.expires = new Date(Date.now() + ttl);
      this.setExpirationTimer(namespace, stringKey, ttl);
    }
    
    // Compress if needed
    if (storageValue.metadata.compressed) {
      storageValue.data = this.compress(value);
    }
    
    // Store the value
    namespaceStorage.set(stringKey, storageValue);
    
    // Emit event
    this.emit({
      type: 'set',
      namespace: String(namespace),
      key: stringKey,
      value
    });
    
    return true;
  }
  
  // Type-safe get method
  get<N extends keyof Schema, K extends keyof Schema[N]>(
    namespace: N,
    key: K
  ): Schema[N][K] | undefined {
    const namespaceStorage = this.storage.get(namespace);
    if (!namespaceStorage) return undefined;
    
    const storageValue = namespaceStorage.get(String(key));
    if (!storageValue) return undefined;
    
    // Check expiration
    if (storageValue.metadata.expires && storageValue.metadata.expires < new Date()) {
      this.delete(namespace, key);
      return undefined;
    }
    
    // Decompress if needed
    if (storageValue.metadata.compressed) {
      return this.decompress(storageValue.data);
    }
    
    return storageValue.data;
  }
  
  // Delete method
  delete<N extends keyof Schema, K extends keyof Schema[N]>(
    namespace: N,
    key: K
  ): boolean {
    const namespaceStorage = this.storage.get(namespace);
    if (!namespaceStorage) return false;
    
    const stringKey = String(key);
    const deleted = namespaceStorage.delete(stringKey);
    
    if (deleted) {
      // Clear expiration timer
      const timerId = `${String(namespace)}:${stringKey}`;
      const timer = this.expirationTimers.get(timerId);
      if (timer) {
        clearTimeout(timer);
        this.expirationTimers.delete(timerId);
      }
      
      // Emit event
      this.emit({
        type: 'delete',
        namespace: String(namespace),
        key: stringKey
      });
    }
    
    return deleted;
  }
  
  // Get all keys in namespace
  keys<N extends keyof Schema>(namespace: N): (keyof Schema[N])[] {
    const namespaceStorage = this.storage.get(namespace);
    if (!namespaceStorage) return [];
    
    return Array.from(namespaceStorage.keys()) as (keyof Schema[N])[];
  }
  
  // Get all values in namespace
  values<N extends keyof Schema>(namespace: N): Schema[N][keyof Schema[N]][] {
    const namespaceStorage = this.storage.get(namespace);
    if (!namespaceStorage) return [];
    
    const values: Schema[N][keyof Schema[N]][] = [];
    
    for (const [key, storageValue] of namespaceStorage) {
      const value = this.get(namespace, key as keyof Schema[N]);
      if (value !== undefined) {
        values.push(value);
      }
    }
    
    return values;
  }
  
  // Clear namespace or entire storage
  clear(namespace?: keyof Schema): void {
    if (namespace) {
      const namespaceStorage = this.storage.get(namespace);
      if (namespaceStorage) {
        // Clear expiration timers
        for (const key of namespaceStorage.keys()) {
          const timerId = `${String(namespace)}:${key}`;
          const timer = this.expirationTimers.get(timerId);
          if (timer) {
            clearTimeout(timer);
            this.expirationTimers.delete(timerId);
          }
        }
        
        namespaceStorage.clear();
      }
    } else {
      // Clear all
      for (const [ns, _] of this.storage) {
        this.clear(ns);
      }
    }
    
    this.emit({ type: 'clear', namespace: namespace ? String(namespace) : undefined });
  }
  
  // Watch for changes
  watch(
    pattern: string | RegExp,
    callback: (event: StorageEvent) => void
  ): () => void {
    const patternKey = pattern instanceof RegExp ? pattern.source : pattern;
    
    if (!this.watchers.has(patternKey)) {
      this.watchers.set(patternKey, new Set());
    }
    
    this.watchers.get(patternKey)!.add(callback);
    
    // Return unwatch function
    return () => {
      const callbacks = this.watchers.get(patternKey);
      if (callbacks) {
        callbacks.delete(callback);
        if (callbacks.size === 0) {
          this.watchers.delete(patternKey);
        }
      }
    };
  }
  
  // Set storage quota for namespace
  setQuota(namespace: keyof Schema, bytes: number): void {
    this.quotas.set(namespace, bytes);
  }
  
  // Get storage stats
  getStats(namespace?: keyof Schema): {
    count: number;
    size: number;
    namespaces?: Record<string, { count: number; size: number }>;
  } {
    if (namespace) {
      const namespaceStorage = this.storage.get(namespace);
      if (!namespaceStorage) {
        return { count: 0, size: 0 };
      }
      
      let size = 0;
      for (const storageValue of namespaceStorage.values()) {
        size += storageValue.metadata.size;
      }
      
      return {
        count: namespaceStorage.size,
        size
      };
    }
    
    // Overall stats
    const stats: {
      count: number;
      size: number;
      namespaces: Record<string, { count: number; size: number }>;
    } = {
      count: 0,
      size: 0,
      namespaces: {}
    };
    
    for (const [ns, namespaceStorage] of this.storage) {
      const nsStats = this.getStats(ns);
      stats.namespaces[String(ns)] = nsStats;
      stats.count += nsStats.count;
      stats.size += nsStats.size;
    }
    
    return stats;
  }
  
  // Migration utilities
  migrate<NewSchema extends StorageSchema>(
    migrations: {
      [N in keyof Schema]?: {
        [K in keyof Schema[N]]?: (oldValue: Schema[N][K]) => NewSchema[N][K];
      };
    }
  ): TypeSafeStorage<NewSchema> {
    const newStorage = new TypeSafeStorage<NewSchema>(this.options);
    
    for (const [namespace, namespaceStorage] of this.storage) {
      const nsMigrations = migrations[namespace];
      
      for (const [key, storageValue] of namespaceStorage) {
        let newValue = storageValue.data;
        
        if (nsMigrations && key in nsMigrations) {
          const migration = nsMigrations[key as keyof typeof nsMigrations];
          if (migration) {
            newValue = migration(storageValue.data);
          }
        }
        
        newStorage.set(
          namespace as keyof NewSchema,
          key as any,
          newValue,
          {
            ttl: storageValue.metadata.expires 
              ? storageValue.metadata.expires.getTime() - Date.now()
              : undefined
          }
        );
      }
    }
    
    return newStorage;
  }
  
  // Private helper methods
  private emit(event: StorageEvent): void {
    for (const [pattern, callbacks] of this.watchers) {
      const matches = this.matchesPattern(event, pattern);
      if (matches) {
        callbacks.forEach(cb => cb(event));
      }
    }
  }
  
  private matchesPattern(event: StorageEvent, pattern: string): boolean {
    const eventKey = `${event.namespace}:${event.type}`;
    
    if (pattern === '*') return true;
    if (pattern === eventKey) return true;
    
    try {
      const regex = new RegExp(pattern);
      return regex.test(eventKey);
    } catch {
      return false;
    }
  }
  
  private setExpirationTimer(namespace: keyof Schema, key: string, ttl: number): void {
    const timerId = `${String(namespace)}:${key}`;
    
    // Clear existing timer
    const existingTimer = this.expirationTimers.get(timerId);
    if (existingTimer) {
      clearTimeout(existingTimer);
    }
    
    // Set new timer
    const timer = setTimeout(() => {
      this.delete(namespace, key as any);
      this.emit({
        type: 'expire',
        namespace: String(namespace),
        key
      });
    }, ttl);
    
    this.expirationTimers.set(timerId, timer);
  }
  
  private startExpirationChecker(): void {
    // Check for expired items every minute
    setInterval(() => {
      const now = new Date();
      
      for (const [namespace, namespaceStorage] of this.storage) {
        for (const [key, storageValue] of namespaceStorage) {
          if (storageValue.metadata.expires && storageValue.metadata.expires < now) {
            this.delete(namespace, key as any);
          }
        }
      }
    }, 60000);
  }
  
  private checkQuota(namespace: keyof Schema, value: any): boolean {
    const quota = this.quotas.get(namespace);
    if (!quota) return true;
    
    const currentStats = this.getStats(namespace);
    const newSize = this.calculateSize(value);
    
    return (currentStats.size + newSize) <= quota;
  }
  
  private calculateSize(value: any): number {
    // Simplified size calculation
    return JSON.stringify(value).length;
  }
  
  private compress(value: any): any {
    // Simplified compression (in real app, use proper compression)
    return JSON.stringify(value);
  }
  
  private decompress(value: any): any {
    // Simplified decompression
    return JSON.parse(value);
  }
}

// Define storage schema
interface MyAppSchema {
  users: {
    [userId: string]: {
      name: string;
      email: string;
      preferences: Record<string, any>;
    };
  };
  cache: {
    [key: string]: any;
  };
  settings: {
    theme: 'light' | 'dark';
    language: string;
    [key: `feature_${string}`]: boolean;
  };
}

// Create storage instance
const storage = new TypeSafeStorage<MyAppSchema>({
  defaultTTL: 3600000, // 1 hour
  compression: true,
  maxSize: 10 * 1024 * 1024 // 10MB
});

// Set quotas
storage.setQuota('cache', 5 * 1024 * 1024); // 5MB for cache
storage.setQuota('users', 3 * 1024 * 1024); // 3MB for users

// Watch for changes
const unwatch = storage.watch('users:*', (event) => {
  console.log('πŸ‘οΈ User storage event:', event);
});

// Use the storage
storage.set('users', 'user_001', {
  name: 'Alice',
  email: '[email protected]',
  preferences: {
    theme: 'dark',
    notifications: true
  }
});

storage.set('cache', 'api_response_1', 
  { data: 'cached response' }, 
  { ttl: 300000 } // 5 minutes
);

storage.set('settings', 'theme', 'dark');
storage.set('settings', 'feature_beta', true);

// Get values
const user = storage.get('users', 'user_001');
console.log('πŸ‘€ User:', user);

// Get stats
console.log('πŸ“Š Storage stats:', storage.getStats());

// Migration example
interface NewSchema extends MyAppSchema {
  users: {
    [userId: string]: {
      id: string; // New field
      name: string;
      email: string;
      preferences: Record<string, any>;
      createdAt: Date; // New field
    };
  };
}

const migratedStorage = storage.migrate<NewSchema>({
  users: {
    user_001: (oldUser) => ({
      ...oldUser,
      id: 'user_001',
      createdAt: new Date()
    })
  }
});

console.log('πŸ”„ Migrated user:', migratedStorage.get('users', 'user_001'));

πŸŽ“ Key Takeaways

You now understand how to leverage index signatures for dynamic property access! Here’s what you’ve learned:

  • βœ… Index signature syntax with string and number keys πŸ”‘
  • βœ… Combining index signatures with known properties πŸ—οΈ
  • βœ… Type safety techniques for dynamic properties πŸ›‘οΈ
  • βœ… Real-world patterns for flexible APIs 🌐
  • βœ… Best practices for maintainable dynamic types ✨

Remember: Index signatures give you flexibility while maintaining type safety - use them wisely to handle dynamic data structures! πŸš€

🀝 Next Steps

Congratulations! πŸŽ‰ You’ve mastered index signatures in TypeScript!

Here’s what to do next:

  1. πŸ’» Practice with the storage system exercise above
  2. πŸ—οΈ Refactor dynamic objects to use proper index signatures
  3. πŸ“š Move on to our next tutorial: Callable and Constructable Interfaces: Function Types
  4. 🌟 Apply index signatures to create flexible, type-safe APIs!

Remember: The best code adapts to changing requirements while maintaining type safety. Keep it dynamic! πŸš€


Happy coding! πŸŽ‰πŸš€βœ¨