⚡IntermediateTypeScript in Practice

Index Signatures & Record Types

Model dynamic keys with index signatures, use Record<K,V> for key-value maps, and understand when to choose each approach.

Overview

When you have an object whose keys are not known at compile time - like a translation map, a cache, or a lookup table - TypeScript provides two tools: **index signatures** and the **`Record`** utility type. An **index signature** (`{ [key: string]: T }`) says "this object can have any string key, and every value is T". It's the most flexible option but the least precise - it also allows accessing non-existent keys and returns T (not `T | undefined`), which can be dangerous unless you enable `"noUncheckedIndexedAccess"` in tsconfig. **`Record<K, V>`** is a mapped type over a known union of keys: `Record<"a" | "b", number>` produces `{ a: number; b: number }`. When K is `string`, it is equivalent to an index signature. When K is a string literal union, it enforces all keys are present - much more precise. The right choice depends on whether keys are known (use `Record<KnownKeys, V>`) or unknown at compile time (use index signature or `Record<string, V>`).

Key Points

  • •Index signature: `{ [key: string]: T }` - any string key maps to T
  • •Index key type can be `string`, `number`, or a template literal type
  • •Record<K, V>: mapped type - `K` can be a string literal union for precise required keys
  • •`Record<string, V>` is equivalent to `{ [key: string]: V }` - use either
  • •`Record<"a" | "b", V>` requires both "a" and "b" to be present - more specific than index signature
  • •`"noUncheckedIndexedAccess"`: tsconfig option that makes index access return `T | undefined` (safer)
  • •Index signatures conflict with specific properties unless their types overlap
  • •Use `Map<K, V>` instead when keys are truly dynamic and you need `.has()` / iteration guarantees

Examples

Index signatures for dynamic keys

// Basic index signature
interface TranslationMap {
  [key: string]: string;
}

const en: TranslationMap = {
  greeting: 'Hello',
  farewell: 'Goodbye',
  error: 'Something went wrong',
};

// Access: TypeScript believes every key returns string
// (unless "noUncheckedIndexedAccess" is enabled)
console.log(en['greeting']); // "Hello"
console.log(en['missing']);  // undefined at runtime, but TS says "string"

// Mix specific properties with index signature
// (specific property type must be compatible with index type)
interface Config {
  [key: string]: string | number | boolean;
  name: string;           // OK  -  string is assignable to string | number | boolean
  version: number;        // OK
  debug: boolean;         // OK
  // id: symbol;          // Error  -  symbol is not assignable to the index type
}

const cfg: Config = { name: 'app', version: 1, debug: false, theme: 'dark' };

Record<K, V> patterns

// Record with specific keys  -  all keys must be present
type Role = 'admin' | 'editor' | 'viewer';
type Permissions = Record<Role, string[]>;

const permissions: Permissions = {
  admin:  ['read', 'write', 'delete'],
  editor: ['read', 'write'],
  viewer: ['read'],
  // Missing any key is a compile error
};

// Record with string  -  same as index signature
type Cache = Record<string, { data: unknown; expiry: number }>;

const cache: Cache = {};
cache['user:1'] = { data: { name: 'Alice' }, expiry: Date.now() + 60_000 };

// Record as function parameter
function translateAll(
  keys: string[],
  translations: Record<string, string>
): string[] {
  return keys.map(k => translations[k] ?? k);
}

const result = translateAll(
  ['hello', 'world', 'missing'],
  { hello: 'Hola', world: 'Mundo' }
);
console.log(result); // ['Hola', 'Mundo', 'missing']

noUncheckedIndexedAccess and safe lookup patterns

// Without noUncheckedIndexedAccess:
const scores: Record<string, number> = { alice: 95 };
const score = scores['bob']; // TypeScript says: number (but it's undefined!)
console.log(score.toFixed(2)); // Runtime TypeError

// With "noUncheckedIndexedAccess": true in tsconfig:
// const score2 = scores['bob']; // TypeScript says: number | undefined
// score2.toFixed(2); // Error: Object is possibly undefined

// Safe lookup utility (works without the tsconfig option)
function safeLookup<K extends string, V>(
  map: Record<K, V>,
  key: K
): V | undefined {
  return (map as Record<string, V | undefined>)[key];
}

// Better alternative for truly dynamic keys: use Map
const dynamicMap = new Map<string, number>();
dynamicMap.set('alice', 95);
dynamicMap.set('bob', 87);

const bobScore = dynamicMap.get('carol'); // number | undefined  -  always safe
console.log(bobScore ?? 'not found'); // "not found"

Common Mistakes

  • Mixing specific property types incompatible with the index signature: `name: symbol` when the index type is `string` - the specific type must be assignable to the index value type
  • Assuming `record[key]` is always defined - with index signatures it is not; use optional chaining or the `?? default` pattern
  • Using `Record<string, V>` when you actually know all the keys - use a string literal union as K to get compile-time exhaustiveness checking

Note

If your object has a fixed set of known keys, use `Record<"key1" | "key2", ValueType>` - TypeScript will ensure all keys are present and no unknown keys creep in. Reserve `Record<string, V>` and index signatures for truly dynamic scenarios like caches and lookup tables.

Exercises

Write TypeScript code to solve each exercise. Click Run to compile and execute it right here, then mark as done.

Loading editor…

Completed: 0 / 3 exercises

Knowledge Check

Question 1 of 4

What is the difference between `{ [key: string]: number }` and `Record<string, number>`?