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.
// 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"]
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.