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:
- Dynamic Data π: Handle data with unknown property names
- API Flexibility π: Work with varying response structures
- Configuration Objects βοΈ: Create extensible settings
- 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
- Be Specific π―: Use the most restrictive type possible
- Consider Alternatives π€: Map, Set, or Record might be better
- Document Patterns π: Explain what keys are expected
- 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:
- π» Practice with the storage system exercise above
- ποΈ Refactor dynamic objects to use proper index signatures
- π Move on to our next tutorial: Callable and Constructable Interfaces: Function Types
- π 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! ππβ¨