OneBug All write-ups

TypeScript ·

Array(n).fill([]) fills every slot with the same array

Array(n).fill([]) looks like it builds n empty arrays, but fill() copies one reference into every slot, so every bucket mutates together. Here is the fix.

Test yourself before reading - the write-up below gives it away.

Spot the bug TypeScript
1
// Split items into `groupCount` round-robin buckets.
2
function partition<T>(items: T[], groupCount: number): T[][] {
3
  const buckets: T[][] = Array(groupCount).fill([]);
4
  items.forEach((item, i) => {
5
    buckets[i % groupCount].push(item);
6
  });
7
  return buckets;
8
}
9
 
10
const result = partition(["a", "b", "c", "d"], 2);
11
console.log(result[0]); // expected: ["a", "c"]
12
console.log(result[1]); // expected: ["b", "d"]
Powered by OneBug

What the code looks like it does

// Split items into `groupCount` round-robin buckets.
function partition<T>(items: T[], groupCount: number): T[][] {
  const buckets: T[][] = Array(groupCount).fill([]);
  items.forEach((item, i) => {
    buckets[i % groupCount].push(item);
  });
  return buckets;
}

const result = partition(["a", "b", "c", "d"], 2);
console.log(result[0]); // expected: ["a", "c"]
console.log(result[1]); // expected: ["b", "d"]

This is a fan-out helper: hand it a list and a bucket count, get back that many buckets with items distributed round-robin. The line that looks like it deserves scrutiny is buckets[i % groupCount].push(item) - modulo indexing is where off-by-one bugs usually live in round-robin code. It is not the problem here; the arithmetic is correct.

The bug: fill() writes one reference into every slot

Array(groupCount) creates an array of length groupCount with empty slots. .fill([]) then writes a value into each of those slots - but the [] literal is evaluated exactly once, before fill starts writing, and the same array object is copied into every position. buckets[0] and buckets[1] are not two empty arrays that happen to look alike; they are the same array, referenced twice.

So when the loop does buckets[i % groupCount].push(item), it does not matter which index it resolves to - every push lands on the one shared array. Both result[0] and result[1] end up pointing at ["a", "b", "c", "d"], the entire input, in order.

TypeScript does not catch this. Array<T>.fill(value: T): T[] is a perfectly well-typed call; nothing about the signature says whether value gets cloned per slot or shared. The type checker only knows a T[][] came back, not that all its elements are aliases. Print buckets.length and it says 2, which looks right. Only reading two different indices and comparing their contents, or mutating one and checking the other, reveals that they are one array wearing two names.

Why it bites in real code

This pattern shows up anywhere something gets "one array per slot" set up before a loop fills it in: sharding webhook events across worker queues, building per-day buckets for a report, initializing rows of a grid for a pathfinding or game-of-life algorithm, seeding per-user caches at startup. In every case the symptom is the same and confusing in the same way - one bucket looks correct (usually the last one written) and the others look like they got "reset" or "overwritten", because from the caller's view that is exactly what happened. Debugging it by adding logs inside the loop often makes it look even more mysterious, since each iteration's push genuinely does add to "its" bucket - the array both readers are staring at is just the same one.

It gets worse across async boundaries. If buckets are handed off to concurrent workers that each append to "their" list before writing it out, you get a race where the last worker to finish silently wins and every output file contains the same merged data, with no exception or type error anywhere to point at the cause.

The fix

Build a fresh array per slot instead of cloning one:

const buckets: T[][] = Array.from({ length: groupCount }, () => []);

Array.from calls its second argument once per index, so each call to () => [] returns a brand new array. Nothing is shared. The same idea works with .map(): new Array(groupCount).fill(null).map(() => []) - the key is that the factory function runs on every iteration, unlike fill, which runs its argument expression once and copies the result.

A tempting non-fix is swapping [] for new Array() inside the same .fill() call. That changes nothing: fill still evaluates the expression once and shares the resulting object, regardless of which syntax constructed it. The bug is in how fill distributes values, not in how the empty array was spelled.

War stories

MDN's own reference page for Array.prototype.fill() carries an explicit warning for exactly this case: filling with an object or array copies the same reference into every slot, and mutating one element mutates them all. It is common enough that the documentation for the method leads with it.

Related bugs

The same "types are happy, only the runtime aliases" trap shows up with a different default in Array.sort() and the string-comparator trap - another built-in whose default behavior optimizes for "never throws" over "does what it looks like". The aliasing itself, minus the fill() angle, also shows up in Go when reslicing backing arrays: see the filter that eats its input. Browse the rest of the bug write-ups.