Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

TypeScript Dictionaries: Record, Index Signatures, and the Lie About Missing Keys

Sean

Platform Writer

Aug 13, 2026
8 min read

Record<string, number> and { [key: string]: number } are the same type written two ways — Record is a mapped type built on the index signature. The trap in both is that TypeScript assumes every lookup succeeds: counts['missing'] is typed number even though it is undefined at runtime. noUncheckedIndexedAccess in tsconfig.json is the single most valuable setting for anyone working with dictionaries.

TypeScript Dictionaries: Record, Index Signatures, and the Lie About Missing Keys

That default is a deliberate ergonomic trade-off by the TypeScript team, and it is the source of most dictionary-shaped bugs. Everything below assumes you would rather have the safety.

Table of contents

Record and index signatures

// These are equivalent
type Counts = Record<string, number>;
type Counts = { [key: string]: number };

// Record's real value is a constrained key set
type Role = 'admin' | 'member' | 'viewer';
type Permissions = Record<Role, string[]>;

// Now every role must be present -- omitting one is an error
const perms: Permissions = {
  admin:  ['read', 'write', 'delete'],
  member: ['read', 'write'],
  viewer: ['read'],
};

That exhaustiveness is the reason to prefer Record when the keys are known. Add a fourth role to the union and every Record<Role, ...> in the codebase becomes a compile error listing exactly what needs updating — which is how you want that change to go.

For an open-ended dictionary the two forms are interchangeable. The index signature form is required when you want extra known properties alongside the open set:

interface Config {
  name: string;                    // known
  [key: string]: string | number;  // plus anything else
}

Note the constraint: every declared property must be assignable to the index signature’s value type. Adding enabled: boolean to the above is an error until boolean joins the union.

The missing-key problem

const counts: Record<string, number> = { apples: 3 };

const n = counts['bananas'];   // typed number
console.log(n.toFixed(2));     // TypeError at runtime -- n is undefined

TypeScript is confidently wrong here, and it compiles without complaint. The fix is one compiler option:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true
  }
}

With it on, counts['bananas'] is number | undefined and the compiler makes you handle it:

const n = counts['bananas'];
if (n !== undefined) {
  console.log(n.toFixed(2));
}

// or
console.log((counts['bananas'] ?? 0).toFixed(2));

It is not part of strict, so you have to opt in explicitly. It produces a wave of new errors in an existing codebase, and essentially every one of them is a place that would have thrown at runtime given the wrong input. Enabling it on a mature project is a genuinely good afternoon’s work.

Narrowing keys with in and hasOwnProperty

function get(counts: Record<string, number>, key: string) {
  if (key in counts) {
    return counts[key];        // narrowed to number
  }
  return 0;
}

// Object.hasOwn ignores the prototype chain -- prefer it
if (Object.hasOwn(counts, key)) {
  return counts[key];
}

in walks the prototype chain, so 'toString' in {} is true. Object.hasOwn checks own properties only and is the correct test for a dictionary. This matters when keys come from user input: a key of constructor or __proto__ finds something that is not your data.

For a dictionary with no prototype at all, Object.create(null) gives you a clean object where the whole question disappears.

Partial, and typing a lookup that may miss

When the keys are a known union but not all of them will be present, Record alone is too strict and Record<string, T> is too loose.

type Role = 'admin' | 'member' | 'viewer';

// All keys required
const full: Record<Role, string[]> = { admin: [], member: [], viewer: [] };

// Any subset, values possibly undefined
const partial: Partial<Record<Role, string[]>> = { admin: ['read'] };

// Keys restricted to the union, but arbitrary string lookups rejected
function can(role: Role, action: string) {
  return (full[role] ?? []).includes(action);
}

Partial<Record<K, V>> is the honest type for a lookup table that may not cover every case, and it forces the ?? default at every use — which is the behaviour you wanted.

When to use Map instead

Objects are not the only option, and for several cases Map is straightforwardly better.

  • Non-string keys. Object keys are coerced to strings, so obj[1] and obj['1'] are the same slot. Map keeps 1 and '1' distinct and accepts objects as keys.
  • Frequent additions and deletions. Map is optimised for it; delete on an object can deoptimise its shape.
  • Insertion order with numeric-looking keys. Object keys that look like integers are ordered numerically first, regardless of insertion order. Map always preserves insertion order.
  • Size. map.size is a property; object size requires Object.keys(o).length.
  • Keys that might collide with prototype properties. Map has no prototype chain issue at all.
const counts = new Map<string, number>();
counts.set('apples', 3);

const n = counts.get('bananas');    // number | undefined, always

// Increment safely
counts.set('apples', (counts.get('apples') ?? 0) + 1);

Map.get returns T | undefined regardless of compiler flags, which makes it honest by default. Its main downside is that it does not serialise to JSON — JSON.stringify(map) gives {}, and you need Object.fromEntries(map) first.

Rough rule: object or Record for a fixed, known set of string keys, especially anything crossing a JSON boundary. Map for a dynamic collection that grows and shrinks at runtime.

Building dictionaries with the right type

const users = [
  { id: 'u1', name: 'Ada' },
  { id: 'u2', name: 'Lin' },
];

// Index by id
const byId: Record<string, typeof users[number]> =
  Object.fromEntries(users.map(u => [u.id, u]));

// Group by a key
function groupBy<T, K extends string>(items: T[], key: (item: T) => K) {
  const out = {} as Partial<Record<K, T[]>>;
  for (const item of items) {
    const k = key(item);
    (out[k] ??= []).push(item);
  }
  return out;
}

Object.groupBy now exists natively and returns a Partial<Record<K, T[]>> shape for exactly this reason — the standard library agrees that a grouped lookup can miss.

One caution on Object.fromEntries: it happily produces an object typed as covering every string key, when in reality it covers exactly the ids in the array. That is the same optimism as the index signature, and noUncheckedIndexedAccess is again what makes it honest.

This matters most where the dictionary is built from data you did not write — a database result set, a config file, an API response. The compiler cannot know which keys are present because they are decided at runtime, so it defers to you, and the flag decides whether you get asked.

How this fits the rest of the stack

Record<K, V> when the keys are a known union and you want exhaustiveness. An index signature when the object has known properties plus an open set. Partial<Record<K, V>> when a known key set may be incompletely filled. Map for dynamic collections and non-string keys.

Above all, turn on noUncheckedIndexedAccess. It is not part of strict, it will produce a lot of errors on first run, and nearly every one is a real crash you have not had yet. If you are working out what the service and database behind that code cost to run, the RunxBuild hosting calculator lists them separately.

Useful related references:

FAQ

What is the difference between Record and an index signature in TypeScript?

They describe the same thing — Record<K, V> is a mapped type built on index signatures. Record is better when the keys are a known union, because it requires every key to be present. An index signature is required when you want specific named properties alongside an open-ended set of keys.

Why does TypeScript not warn about missing dictionary keys?

By default TypeScript assumes an index lookup succeeds, so counts['missing'] is typed as the value type rather than value | undefined. It is a deliberate ergonomic choice. Enable noUncheckedIndexedAccess in tsconfig.json to make lookups include undefined and force you to handle the miss.

Should I use a Map or an object for a dictionary in TypeScript?

Use Map for dynamic collections, non-string keys, frequent insertion and deletion, or when you need reliable insertion order. Use an object or Record for a fixed set of string keys, especially anything that has to serialise to JSON, since JSON.stringify on a Map produces an empty object.

How do I safely check whether a key exists in a TypeScript object?

Use Object.hasOwn(obj, key) rather than key in obj. The in operator walks the prototype chain, so 'toString' in {} is true, which matters when keys come from user input. Object.hasOwn also narrows the type for subsequent access.

How do I type a lookup table that might not cover every case?

Partial<Record<Key, Value>>. The keys stay restricted to your union so typos are caught, but the values are possibly undefined, which forces a default at each use site. This is the same shape the native Object.groupBy returns, for the same reason.

#typescript#record type#index signature#dictionary#map