3.4 Index Signatures and Record
Programming frequently requires managing key-value pairs dynamically. Translation string tables, caches, HTTP header maps, and configuration dictionaries are common examples. TypeScript provides index signatures and the Record utility type to handle such dynamic objects in a type-safe way.
Basic Index Signature Syntax
An index signature declares that an object can have dynamic keys. It is written in the form [keyVariableName: KeyType]: ValueType.
String Indexer [key: string]: T
interface StringDictionary {
[key: string]: string;
}
const translations: StringDictionary = {
hello: "Hello",
goodbye: "Goodbye",
thank_you: "Thank you",
};
// Keys can be added dynamically
translations["good_morning"] = "Good morning";
// Accessing a non-existent key → undefined at runtime, string at compile time
const result = translations["unknown"]; // type: string (actually undefined at runtime)
The key variable name (here key) is purely for readability and can be anything.
interface HttpHeaders {
[headerName: string]: string;
}
interface Cache {
[cacheKey: string]: unknown;
}
Numeric Indexer [key: number]: T
interface NumericArray {
[index: number]: string;
}
const fruits: NumericArray = {
0: "apple",
1: "banana",
2: "cherry",
};
console.log(fruits[0]); // "apple"
console.log(fruits[3]); // undefined (runtime), string (compile time)
Because JavaScript converts numeric keys to strings at runtime, the value type of a numeric indexer must be a subtype of the string indexer's value type.
interface Mixed {
[key: string]: string | number;
[key: number]: string; // string is a subtype of string | number → valid
}
Limitations of Index Signatures
Type Compatibility with Explicit Properties
When an interface has an index signature, any explicit properties added to it must be compatible with the indexer's value type.
interface GoodMixed {
[key: string]: string | number | boolean;
name: string; // string ⊂ (string | number | boolean) → valid
age: number; // number ⊂ (string | number | boolean) → valid
active: boolean; // boolean ⊂ (string | number | boolean) → valid
}
// Error case
interface BadMixed {
[key: string]: string;
// count: number; // Error: number is not compatible with string
// data: object; // Error: object is not compatible with string
}
Because of this restriction, the value type of an index signature is often widened to unknown or any, at the cost of type safety.
No Optional Index Signatures
You cannot attach ? to an index signature itself. Instead, include undefined in the value type.
// interface Bad { [key: string]?: string; } // Syntax error
interface AllowUndefined {
[key: string]: string | undefined; // allows undefined
}
The Record<K, V> Utility Type
Record<K, V> is a utility that creates an object type composed of key type K and value type V. It is implemented internally as a mapped type.
// Internal implementation of Record
type Record<K extends keyof any, T> = {
[P in K]: T;
};
Basic Usage
// All string keys with number values
type ScoreMap = Record<string, number>;
const scores: ScoreMap = { Alice: 95, Bob: 87, Charlie: 92 };
// Restrict to specific keys only
type DayOfWeek = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
type WeeklySchedule = Record<DayOfWeek, string[]>;
const schedule: WeeklySchedule = {
mon: ["Stand-up meeting", "Code review"],
tue: ["Planning session"],
wed: ["Sprint planning"],
thu: ["1:1 meeting"],
fri: ["Retrospective"],
sat: [],
sun: [],
};
// A missing key causes a compile error → all keys must be defined
Combined with Generics
// State machine transition table
type TransitionMap<S extends string> = Record<S, Partial<Record<S, boolean>>>;
type TrafficLightState = "red" | "yellow" | "green";
const transitions: TransitionMap<TrafficLightState> = {
red: { green: true }, // red → green allowed
yellow: { red: true }, // yellow → red allowed
green: { yellow: true }, // green → yellow allowed
};
Index Signature vs Record vs Map Comparison
| Characteristic | Index Signature | Record<K, V> | Map<K, V> |
|---|---|---|---|
| Exists at type level | Compile time | Compile time | Runtime + type |
| Key type restriction | string | number | symbol | Subtype of keyof any | Any type |
| Enforce specific key set | No | Yes (with literal unions) | No |
| Runtime size lookup | Object.keys().length | Object.keys().length | .size |
| Order guarantee | Insertion order (unofficial) | Insertion order (unofficial) | Insertion order (official) |
| JSON serialization | Direct | Direct | Requires extra handling |
| Iterable | for...in | for...in | for...of |
// Index signature: flexible structure but cannot enforce a key set
interface Config {
[key: string]: unknown;
version: string; // can be mixed in (when type compatibility is satisfied)
}
// Record: can enforce a specific key set, JSON-friendly
const permissions: Record<"read" | "write" | "admin", boolean> = {
read: true,
write: false,
admin: false,
// extra: true, // Error: key not allowed
};
// Map: can use objects as keys, runtime order is guaranteed
const userCache = new Map<string, User>();
userCache.set("user-1", { id: "1", name: "Alice", email: "a@b.com", username: "alice", isActive: true });
console.log(userCache.size); // 1
When to use what:
- Key is any
stringand JSON serialization is needed → index signature orRecord<string, V> - A specific key set must be enforced →
Record<LiteralUnion, V> - Object keys are needed or insertion order matters →
Map
The keyof typeof Pattern for Extracting Dynamic Object Key Types
This is a powerful pattern for extracting key types from runtime objects.
const HTTP_STATUS = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
INTERNAL_ERROR: 500,
} as const;
// typeof extracts the type of the runtime object
type HttpStatusObject = typeof HTTP_STATUS;
// { readonly OK: 200; readonly CREATED: 201; ... }
// keyof extracts the key type
type HttpStatusKey = keyof typeof HTTP_STATUS;
// "OK" | "CREATED" | "NO_CONTENT" | "BAD_REQUEST" | ...
// Extracting the value type
type HttpStatusValue = (typeof HTTP_STATUS)[HttpStatusKey];
// 200 | 201 | 204 | 400 | 401 | 403 | 404 | 500
function getStatusMessage(key: HttpStatusKey): string {
const messages: Record<HttpStatusKey, string> = {
OK: "Success",
CREATED: "Created",
NO_CONTENT: "No Content",
BAD_REQUEST: "Bad Request",
UNAUTHORIZED: "Unauthorized",
FORBIDDEN: "Forbidden",
NOT_FOUND: "Not Found",
INTERNAL_ERROR: "Internal Server Error",
};
return messages[key];
}
console.log(getStatusMessage("NOT_FOUND")); // "Not Found"
Using Objects Instead of Enums
const ROLES = {
USER: "user",
MODERATOR: "moderator",
ADMIN: "admin",
SUPERADMIN: "superadmin",
} as const;
type Role = (typeof ROLES)[keyof typeof ROLES];
// "user" | "moderator" | "admin" | "superadmin"
function hasPermission(userRole: Role, requiredRole: Role): boolean {
const hierarchy = Object.values(ROLES);
return hierarchy.indexOf(userRole) >= hierarchy.indexOf(requiredRole);
}
The PropertyKey Type
PropertyKey is a TypeScript built-in type representing all types that can be used as object keys.
// Definition of PropertyKey
type PropertyKey = string | number | symbol;
Use it when you want an index signature that also accepts symbol keys.
type AnyMap = Partial<Record<PropertyKey, unknown>>;
const metadata: AnyMap = {
name: "Alice",
42: "answer",
[Symbol.iterator]: function* () { yield 1; },
};
Practical Example: Multilingual Translation Object, Cache Map, Configuration Dictionary
Multilingual Translation System
type Locale = "ko" | "en" | "ja" | "zh";
type TranslationKey =
| "greeting"
| "farewell"
| "error.notFound"
| "error.unauthorized"
| "button.submit"
| "button.cancel";
// Enforce all locale and key combinations
type TranslationTable = Record<Locale, Record<TranslationKey, string>>;
const translations: TranslationTable = {
ko: {
greeting: "안녕하세요",
farewell: "안녕히 가세요",
"error.notFound": "페이지를 찾을 수 없습니다",
"error.unauthorized": "로그인이 필요합니다",
"button.submit": "제출",
"button.cancel": "취소",
},
en: {
greeting: "Hello",
farewell: "Goodbye",
"error.notFound": "Page not found",
"error.unauthorized": "Login required",
"button.submit": "Submit",
"button.cancel": "Cancel",
},
ja: {
greeting: "こんにちは",
farewell: "さようなら",
"error.notFound": "ページが見つかりません",
"error.unauthorized": "ログインが必要です",
"button.submit": "送信",
"button.cancel": "キャンセル",
},
zh: {
greeting: "你好",
farewell: "再见",
"error.notFound": "页面未找到",
"error.unauthorized": "需要登录",
"button.submit": "提交",
"button.cancel": "取消",
},
};
function t(locale: Locale, key: TranslationKey): string {
return translations[locale][key];
}
console.log(t("en", "greeting")); // "Hello"
console.log(t("en", "button.submit")); // "Submit"
TTL-Based Cache Map
interface CacheEntry<T> {
value: T;
expiresAt: number; // Unix timestamp
}
class TypedCache<T> {
private store: Record<string, CacheEntry<T>> = {};
set(key: string, value: T, ttlMs: number): void {
this.store[key] = {
value,
expiresAt: Date.now() + ttlMs,
};
}
get(key: string): T | null {
const entry = this.store[key];
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
delete this.store[key];
return null;
}
return entry.value;
}
has(key: string): boolean {
return this.get(key) !== null;
}
invalidate(key: string): void {
delete this.store[key];
}
purgeExpired(): number {
let count = 0;
const now = Date.now();
for (const key of Object.keys(this.store)) {
if (now > this.store[key].expiresAt) {
delete this.store[key];
count++;
}
}
return count;
}
}
// Usage example
const userCache = new TypedCache<User>();
userCache.set("user-1", { id: "1", name: "Alice", email: "a@b.com", username: "alice", isActive: true }, 60_000);
const cached = userCache.get("user-1"); // User | null
Configuration Dictionary
type ConfigValue = string | number | boolean | string[] | null;
interface AppConfig {
[section: string]: Record<string, ConfigValue>;
}
const config: AppConfig = {
database: {
host: "localhost",
port: 5432,
name: "myapp",
ssl: true,
poolSize: 10,
},
redis: {
host: "localhost",
port: 6379,
db: 0,
ttl: 3600,
},
auth: {
jwtSecret: "secret-key",
tokenExpiry: "7d",
allowedOrigins: ["https://app.example.com"],
},
};
function getConfig<T extends ConfigValue>(
section: string,
key: string,
defaultValue: T
): T {
const sectionConfig = config[section];
if (!sectionConfig) return defaultValue;
const value = sectionConfig[key];
return (value ?? defaultValue) as T;
}
const dbPort = getConfig("database", "port", 5432); // number
const jwtSecret = getConfig("auth", "jwtSecret", ""); // string
Pro Tips: Safe Index Access with noUncheckedIndexedAccess
By default, TypeScript does not include undefined in the type of a value accessed through an index signature. This can lead to runtime errors.
const map: Record<string, number> = { a: 1 };
// Without noUncheckedIndexedAccess: type is number
const value = map["b"]; // type: number, runtime: undefined
console.log(value.toFixed(2)); // Runtime TypeError!
Enabling noUncheckedIndexedAccess in tsconfig.json automatically adds | undefined to all index access results.
// tsconfig.json
{
"compilerOptions": {
"noUncheckedIndexedAccess": true
}
}
// After enabling noUncheckedIndexedAccess
const map: Record<string, number> = { a: 1 };
const value = map["b"]; // type: number | undefined
// console.log(value.toFixed(2)); // Error: 'value' may be undefined
// Safe access pattern 1: optional chaining
console.log(value?.toFixed(2)); // prints undefined
// Safe pattern 2: nullish coalescing
const safe = value ?? 0;
console.log(safe.toFixed(2)); // "0.00"
// Safe pattern 3: type guard
if (value !== undefined) {
console.log(value.toFixed(2)); // safe
}
// Safe pattern 4: hasOwnProperty check
function safeGet<T>(obj: Record<string, T>, key: string): T | undefined {
return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
}
Also Applies to Array Access
// With noUncheckedIndexedAccess enabled
const arr = [1, 2, 3];
const first = arr[0]; // type: number | undefined
const last = arr[arr.length - 1]; // type: number | undefined
// Defensive code
function safeFirst<T>(arr: T[]): T | undefined {
return arr[0]; // safe under noUncheckedIndexedAccess
}
// Use destructuring instead of index access
const [head, ...tail] = arr;
console.log(head); // type: number (not undefined — destructuring is treated differently)
Summary Table
| Tool | Syntax | Characteristics | Best For |
|---|---|---|---|
| String indexer | [key: string]: T | Allows any string key | HTTP headers, arbitrary dictionaries |
| Numeric indexer | [key: number]: T | Number keys, array-like | Array-like structures |
Record<string, V> | Record<string, V> | Same as indexer, more concise | General key-value maps |
Record<K, V> (literal) | Record<"a" | "b", V> | Enforces specific keys | Enum-like objects |
Map<K, V> | Runtime class | Object keys, order guaranteed | Complex keys, order matters |
keyof typeof | keyof typeof obj | Extract key types from runtime objects | Types based on const objects |
noUncheckedIndexedAccess | tsconfig option | Adds | undefined to index access | Maximum type safety |
What's Next...
Section 3.5 covers function types in TypeScript, exploring the many ways to define the type of a function. Topics include function overloads, callback types, the this parameter, and covariance/contravariance for a deep understanding of function type compatibility.