Record<string, T> hid a missing key as defined

Record<string, T> types every key as present, so a typo compiled and getChain returned undefined. Thirteen tests failed from one missing key.

Thirteen tests failed at once. Spies that “were not called”, actionFns is not iterable, auto-approval tests bailing to their catch block, chain getters throwing because something was not iterable. It looked like three separate bugs. It was one: getChain was returning undefined.

I was partway through rewriting GitProxy’s plugin insertion to build chains from phase markers instead of splicing into module-level arrays. The new builder returned an object, getChain read a key off it, and TypeScript was happy the whole time.

What the type promised, and what was actually there

The memoised result was typed like this:

let builtChains: Record<string, ProcessorExec[]>;

Record<Keys, Type> is an index signature. builtChains.pull is typed as ProcessorExec[], never undefined, regardless of whether that key exists on the object. So a mismatch between the keys buildAllChains produced and the keys getChain read compiled cleanly and only showed up at runtime as “not iterable”.

That is exactly how index signatures work: a string index means “any string you pass is a valid key, and the value is T”. It cannot express “these four keys, and only these four”.

The failing tests all consumed the return value as an array. executeChain spread it, spies were installed on processors that never ran because there was no chain to run, and the auto-approval tests failed because the executor hit its catch before reaching any processor. One undefined, thirteen reds.

Give it a real shape

interface BuiltChains {
  branch: ProcessorExec[];
  tag: ProcessorExec[];
  pull: ProcessorExec[];
  default: ProcessorExec[];
}

let builtChains: BuiltChains | undefined;

Now a typo in either direction is a red squiggle. builtChains.pul does not compile, and return { branche: ..., tag: ..., pull: ..., default: ... } does not either. The | undefined on the variable is the other half: it forces the builtChains ??= buildAllChains() path to be obvious, instead of letting you read a key off a value that has not been assigned yet.

Turning on noUncheckedIndexedAccess project-wide would have caught this too. With that flag, any index access becomes T | undefined, including Record<string, T>[string], so you cannot treat builtChains.pull as an array without narrowing. It is the right default for this class of bug. It is also a larger change than you want in the middle of a refactor, which is why the named interface is the local fix and the flag is the longer-term one.

Why Record<string, T> is so tempting here

The builder really does look like a string-keyed map: branch, tag, pull, default, and a switch that picks one. Record<string, ProcessorExec[]> feels like the honest type. The problem is that “honest” in that sentence means “open to any string”, which is the opposite of what the switch needs. The keys are a closed set. A closed set wants a named type, or at least Record<'branch' | 'tag' | 'pull' | 'default', ProcessorExec[]>.

The union-of-keys form is a reasonable middle ground if you do not want a whole interface. It still types every value as ProcessorExec[] and still rejects unknown keys on the object literal, which is most of the value.

The takeaway

If a value is undefined at runtime and TypeScript never mentioned the possibility, look at the index signature before you look at the runtime code. Record<string, T> will not tell you that a key is missing, and a whole suite can fail in unrelated-looking ways because every test assumed the array was there. Name the keys. If you cannot, turn on noUncheckedIndexedAccess so the next missing key is a type error instead of a wall of red.