Array.fill(object) Trap: The N Users Who Are Actually One2026-08-21
You're bootstrapping a batch of records with sensible defaults. Array.fill feels like the perfect one-liner. Ship it, watch your first user break every other user's settings.
// Initialize `count` user records, each with default settings.
function initializeUsers(count) {
const defaults = { notifications: true, theme: 'light' };
const users = new Array(count).fill({ id: null, settings: defaults });
users.forEach((user, i) => {
user.id = i;
});
return users;
}
const users = initializeUsers(3);
users[0].settings.theme = 'dark';
console.log(users[0].id); // expected 0
console.log(users[1].id); // expected 1
console.log(users[2].settings.theme); // expected 'light'
Three users, three IDs, one dark-mode preference. Right? Actual output:
2
2
dark
Array.prototype.fill(value) evaluates value once and stores that same reference in every slot. It does not clone. It does not re-invoke. Your "three users" are three pointers to the same object literal. When the forEach loop assigns user.id = i, it's overwriting the single shared object's id field three times — the last write wins, so every element reports 2. When you mutate users[0].settings.theme, all three see it because settings is the exact same nested object too.
The trap has a nasty ally: this code looks correct under primitive fills. new Array(3).fill(0) genuinely gives you three zeros, because primitives are copied by value. Developers generalize from that mental model and get burned the moment the fill value is an object, array, or function. Static analysis rarely flags it; unit tests that only check users.length === 3 pass cheerfully.
Worse, the bug often survives code review. The mutation and the fill can be dozens of lines apart — a factory function fills the array, a downstream handler mutates one element, and a completely unrelated read sees the corruption hours later. It looks like a race condition, but JavaScript is single-threaded; it's just shared state hiding in plain sight.
Use Array.from with a mapper, which invokes the callback for each slot and returns a fresh object:
function initializeUsers(count) {
return Array.from({ length: count }, (_, i) => ({
id: i,
settings: { notifications: true, theme: 'light' },
}));
}
Now each slot gets its own object literal, and the nested settings is fresh per user too — mutating one leaves the others alone. If you truly want to share a default across all rows (rare, and usually a mistake), do it explicitly with Object.freeze so accidental writes throw in strict mode instead of silently poisoning siblings.
Two rules of thumb worth internalizing:
fill is for primitives. The moment your fill value is an object, array, function, or anything with identity, reach for Array.from({length}, factory).Array.fill(obj) stores one reference in every slot — use Array.from({length: n}, factory) whenever the value has identity.
