TypeScriptIndex Signatures

Index Signatures

Index signatures let you describe objects whose property keys are not known at compile time — only their type is known. They are the TypeScript answer to the classic JavaScript pattern of using a plain object as a dictionary or lookup map.

The problem index signatures solve

Suppose you are building a simple word-count feature. You receive raw text, split it into words, and want to store how many times each word appears. The exact words are only known at runtime, so you cannot list them as named properties.

TS
// Without index signatures TypeScript will reject this:
const counts = {};
counts["hello"] = 1; // Error: Element implicitly has an 'any' type
counts["world"] = 2; // because expression of type 'string' can't be used to index type '{}'.

An index signature tells TypeScript: "any string key is acceptable here, and its value will be a number".

TS
interface WordCount {
  [word: string]: number;
}

const counts: WordCount = {};
counts["hello"] = 1; // ✓
counts["world"] = 2; // ✓
console.log(counts["hello"]); // number
Index signature syntax

The syntax has three parts inside square brackets:

``` { [paramName: keyType]: valueType } ```

  • paramName — a label for documentation only (e.g. `key`, `id`, `word`). TypeScript ignores this name at compile time; it is purely for readability.
  • keyType — must be `string`, `number`, `symbol`, or a template literal type. You cannot use arbitrary unions like `"a" | "b"`.
  • valueType — the type every value under that key must have.

TS
// String index signature
interface StringMap {
  [key: string]: string;
}

// Number index signature
interface NumberMap {
  [index: number]: string;
}

// Symbol index signature (rare, but valid)
interface SymbolMap {
  [key: symbol]: boolean;
}
Note
The parameter name (e.g. key, index) has zero effect on type-checking. Choose something that communicates intent to future readers.
Numeric index signatures

A numeric index signature accepts numbers as keys. This is most common when modelling array-like or tuple-like structures where you access items by position.

TS
interface StringArray {
  [index: number]: string;
}

const fruits: StringArray = ["apple", "banana", "cherry"];
console.log(fruits[0]); // "apple"

// Works equally well with an object literal:
const lookup: StringArray = {
  0: "zero",
  1: "one",
  2: "two",
};

Important rule: when an object has both a string and a numeric index signature, the numeric signature's value type must be assignable to the string signature's value type.

Why? Because JavaScript coerces numeric keys to strings under the hood — `obj[0]` and `obj["0"]` access the same slot — so TypeScript enforces consistency.

TS
// ✓ number value (string) is assignable to string value (string | number)
interface OK {
  [n: number]: string;
  [s: string]: string | number;
}

// ✗ number value (object) is NOT assignable to string value (string)
interface Bad {
  [n: number]: object; // Error: numeric index type 'object' is not assignable to string index type 'string'
  [s: string]: string;
}
Combining index signatures with named properties

You can mix explicit (named) properties with an index signature in the same interface. The catch: every named property's type must be assignable to the index signature's value type.

Think of the index signature as a contract for all properties that do not have an explicit declaration. Named properties are just known members of that same contract, so they must honour it.

TS
interface Config {
  [key: string]: string | number | boolean;
  host: string;        // ✓ string is assignable to string | number | boolean
  port: number;        // ✓ number is assignable to string | number | boolean
  debug: boolean;      // ✓ boolean is assignable to string | number | boolean
}

const config: Config = {
  host: "localhost",
  port: 3000,
  debug: true,
  timeout: 5000,       // extra dynamic key — also fine
  region: "us-east-1", // another dynamic key
};

TS
// Common mistake — named property type doesn't fit the index signature
interface Broken {
  [key: string]: string;
  count: number; // Error: Property 'count' of type 'number' is not assignable
                 // to 'string' index type 'string'.
}
Warning
This constraint surprises many developers. If you need named properties with different value types, widen the index signature value type (e.g. use a union) or switch to Record plus an intersection type.
Record<K, V> — the cleaner alternative

TypeScript ships a built-in utility type called `Record` that expresses the same idea with less syntax noise:

```ts type Record<K extends keyof any, V> = { [P in K]: V }; ```

Use `Record` when:

  • You want a dictionary with a fixed set of string-literal keys.
  • You want a dictionary with any string key (pass `string` as `K`).
  • You need a quick object type without declaring a full interface.

TS
// Any string key → number value
type WordCount = Record<string, number>;

// Fixed string-literal keys
type RolePermissions = Record<"admin" | "editor" | "viewer", boolean>;

const perms: RolePermissions = {
  admin: true,
  editor: true,
  viewer: false,
};

// Equivalent to an explicit interface with an index signature
type Cache = Record<string, unknown>;
// is the same as: { [key: string]: unknown }

Approach

Best for

Allows named props

Key constraint

Index signature interface

Full interface with mixed members

Yes (must match value type)

string, number, symbol, template literal

Record<K, V>

Simple dictionaries, quick one-liners

No (it is a mapped type)

Any type extending keyof any

Mapped type

Transforming existing key sets

No (unless intersected)

keyof T or literal union

Index signatures in interfaces vs type aliases

Both `interface` and `type` support index signatures. The choice mostly follows the general interface vs type preference in your codebase, but there are a few practical differences worth knowing.

TS
// Interface — can be extended / merged (declaration merging)
interface Dictionary {
  [key: string]: unknown;
}

interface ExtendedDictionary extends Dictionary {
  version: string; // version must be assignable to 'unknown' — it is, so fine
}

// Type alias — cannot be merged, but supports more complex expressions
type FlexDict = { [key: string]: string | number };
Tip
Prefer interface for public API shapes that consumers might extend. Prefer type for utility or computed shapes.
Mapped types — the more powerful alternative

Index signatures say "any key of this type maps to this value type". Mapped types go further: they iterate over a specific set of keys and can transform each one independently.

This is why `Record<K, V>` is implemented as a mapped type internally — it is strictly more expressive.

TS
// Index signature: keys are open-ended
interface AnyStringToNumber {
  [key: string]: number;
}

// Mapped type: keys are constrained to a union
type StatusMap = {
  [K in "pending" | "active" | "archived"]: number;
};

const s: StatusMap = { pending: 0, active: 3, archived: 12 };
// s.unknown = 1; // Error — 'unknown' is not in the union

TS
// Mapped types can derive value types from the key
interface Routes {
  home: { path: "/" };
  about: { path: "/about" };
  blog: { path: "/blog"; id: number };
}

// Extract the path string for each route
type RoutePaths = {
  [K in keyof Routes]: Routes[K]["path"];
};
// Result: { home: "/"; about: "/about"; blog: "/blog" }

// Make all values optional
type PartialRoutes = {
  [K in keyof Routes]?: Routes[K];
};
Note
Use an index signature when the set of keys is genuinely unlimited at compile time. Use a mapped type when you know the universe of keys — it gives you better autocomplete and error messages.
Practical example: translation dictionary

A very common real-world use case for index signatures is a translation / i18n dictionary, where the keys are message IDs (strings) and the values are translated strings.

TS
interface TranslationDictionary {
  [messageId: string]: string;
}

const en: TranslationDictionary = {
  "nav.home": "Home",
  "nav.about": "About",
  "error.notFound": "Page not found",
  "error.serverError": "Something went wrong",
};

const de: TranslationDictionary = {
  "nav.home": "Startseite",
  "nav.about": "Über uns",
  "error.notFound": "Seite nicht gefunden",
  "error.serverError": "Etwas ist schiefgelaufen",
};

function translate(dict: TranslationDictionary, key: string): string {
  return dict[key] ?? key; // fall back to the key itself if missing
}

console.log(translate(en, "nav.home")); // "Home"
console.log(translate(de, "nav.home")); // "Startseite"

For larger projects, a mapped type over a known set of keys gives better type safety because it catches missing or misspelled message IDs at compile time:

TS
type MessageKeys =
  | "nav.home"
  | "nav.about"
  | "error.notFound"
  | "error.serverError";

type TypedDictionary = Record<MessageKeys, string>;

const typedEn: TypedDictionary = {
  "nav.home": "Home",
  "nav.about": "About",
  "error.notFound": "Page not found",
  "error.serverError": "Something went wrong",
  // "nav.contact": "..." // Error — not in MessageKeys
};
Practical example: in-memory cache

A cache that stores fetched data by URL is a natural fit for an index signature. The keys (URLs) are arbitrary strings; the values are cache entries.

TS
interface CacheEntry<T> {
  data: T;
  fetchedAt: number; // Unix timestamp
  ttl: number;       // milliseconds
}

interface Cache<T> {
  [url: string]: CacheEntry<T>;
}

const apiCache: Cache<unknown> = {};

function setCache<T>(url: string, data: T, ttlMs = 60_000): void {
  apiCache[url] = { data, fetchedAt: Date.now(), ttl: ttlMs };
}

function getCache<T>(url: string): T | null {
  const entry = apiCache[url] as CacheEntry<T> | undefined;
  if (!entry) return null;
  if (Date.now() - entry.fetchedAt > entry.ttl) {
    delete apiCache[url];
    return null;
  }
  return entry.data;
}

setCache("/api/users", [{ id: 1, name: "Alice" }]);
const users = getCache<{ id: number; name: string }[]>("/api/users");
console.log(users); // [{ id: 1, name: 'Alice' }]
Practical example: dynamic config store

Feature-flag systems and runtime config stores often arrive as flat key/value maps from an API. An index signature with a union value type models this cleanly.

TS
type ConfigValue = string | number | boolean | null;

interface AppConfig {
  // Known keys with specific types
  appName: string;
  version: string;
  maxRetries: number;
  // Catch-all for unknown feature flags / overrides
  [key: string]: ConfigValue;
}

const config: AppConfig = {
  appName: "LetCodes",
  version: "2.1.0",
  maxRetries: 3,
  // Feature flags loaded from remote config
  "feature.darkMode": true,
  "feature.betaEditor": false,
  "experiment.newOnboarding": true,
};

function getFlag(key: string): boolean {
  const val = config[key];
  return val === true;
}

console.log(getFlag("feature.darkMode"));    // true
console.log(getFlag("feature.betaEditor"));  // false
The critical pitfall: undefined in index signatures

This is the single most common footgun with index signatures.

When you read a key that does not exist in the object, JavaScript returns `undefined`. But TypeScript's default behaviour tells you the value is `ValueType`, not `ValueType | undefined`. This creates a lie in the type system.

TS
interface WordCount {
  [word: string]: number;
}

const counts: WordCount = { hello: 1 };

const n = counts["missing"]; // TypeScript says: number
//    ^ This is actually undefined at runtime!

// This will silently produce NaN at runtime:
const doubled = n * 2;
console.log(doubled); // NaN

There are two ways to fix this.

Option 1 — Enable noUncheckedIndexedAccess (recommended)

Add this to your tsconfig.json:

```json { "compilerOptions": { "noUncheckedIndexedAccess": true } } ```

With this flag, TypeScript automatically adds `| undefined` to every index signature read, forcing you to handle the missing case.

TS
// With noUncheckedIndexedAccess: true
interface WordCount {
  [word: string]: number;
}

const counts: WordCount = { hello: 1 };

const n = counts["missing"]; // TypeScript says: number | undefined

// Now you must guard it:
if (n !== undefined) {
  console.log(n * 2);
}

// Or use nullish coalescing:
const safe = (counts["missing"] ?? 0) * 2;
console.log(safe); // 0

Option 2 — Include undefined in the value type manually

If you cannot change tsconfig.json, explicitly include `undefined` in the value type:

TS
interface SafeWordCount {
  [word: string]: number | undefined;
}

const counts: SafeWordCount = { hello: 1 };
const n = counts["missing"]; // number | undefined — TypeScript knows

const result = (n ?? 0) + 10;
console.log(result); // 10
Warning
Never silently trust an index-signature read. Always guard with !== undefined, a nullish coalescing operator, or enable noUncheckedIndexedAccess in your tsconfig.
Readonly index signatures

You can prevent mutation of indexed properties by adding the `readonly` modifier. This is useful for immutable lookup tables loaded once at startup.

TS
interface ReadonlyDictionary {
  readonly [key: string]: string;
}

const iso3166: ReadonlyDictionary = {
  US: "United States",
  DE: "Germany",
  CA: "Canada",
};

console.log(iso3166["CA"]); // "Canada"
iso3166["CA"] = "Canada (modified)"; // Error: Index signature in type 'ReadonlyDictionary'
                                      // only permits reading.
Template literal index signatures

TypeScript 4.4+ allows template literal types as the key type in index signatures. This lets you constrain keys to a specific shape — for example, all keys must start with "on".

TS
interface EventHandlers {
  [key: `on${string}`]: () => void;
}

const handlers: EventHandlers = {
  onClick: () => console.log("clicked"),
  onHover: () => console.log("hovered"),
  onFocus: () => console.log("focused"),
};

// handlers.click = () => {};  // Error — 'click' does not match 'on${string}'
Tip
Template literal index signatures are a great way to enforce naming conventions (prefixes, suffixes) at the type level without enumerating every possible key.
Common mistakes at a glance
  • Forgetting that all named properties must match the index signature value type — widen the value type to a union if needed.

  • Assuming an indexed read always returns the value type — always handle the possible undefined case.

  • Using an index signature when you know the exact set of keys — use a mapped type or Record with a union instead for better autocomplete.

  • Putting a union of string literals as the key type — this is not valid syntax; use a mapped type instead.

  • Confusing the parameter name (key, index, id) for something TypeScript uses — it is documentation only.

Quick reference

Pattern

Syntax or example

Basic string index signature

{ [key: string]: number }

Numeric index signature

{ [index: number]: string }

Named props + index signature

{ id: number; [key: string]: number }

Readonly index signature

{ readonly [key: string]: string }

Template literal keys

{ [key: on${string}]: () => void }

Record equivalent

Record<string, number>

Safe undefined handling

enable noUncheckedIndexedAccess in tsconfig

Success
You now understand index signatures from first principles. The key mental model: an index signature is a promise about the *type* of all dynamically-keyed properties. Use them for open-ended dictionaries, consider Record for conciseness, prefer mapped types when the key set is known, and always guard indexed reads against undefined.