Array Use Cases
Set operations, grouping, sorting traps, and the array behaviours that break things without an error.
Array methods you can look up. The interesting part is the handful of places where arrays behave in ways nobody expects on a Tuesday afternoon.
Set operations
const a = [1, 2, 3, 4];
const b = [3, 4, 5];
const intersection = a.filter((value) => b.includes(value)); // [3, 4]
const difference = a.filter((value) => !b.includes(value)); // [1, 2]
const union = [...new Set([...a, ...b])]; // [1, 2, 3, 4, 5]
includes inside filter is O(n × m). On anything bigger than a few hundred
items, build a Set once:
const lookup = new Set(b);
const intersection = a.filter((value) => lookup.has(value));
includes finds NaN and indexOf never does. [NaN].indexOf(NaN) is -1
but [NaN].includes(NaN) is true.
The old indexOf fallback also needs !== -1, not >= -1. Since >= -1 is
true for every value indexOf can return, the filter becomes a no-op that
returns everything and never errors.
Sets compare by reference, so objects need a key:
const seen = new Set(b.map((user) => user.id));
const intersection = a.filter((user) => seen.has(user.id));
Dedupe
const unique = [...new Set(["a", "b", "a"])]; // ["a", "b"]
// Objects, keeping the last occurrence per id
const uniqueUsers = [...new Map(users.map((u) => [u.id, u])).values()];
Map preserves insertion order and the later entry wins, which is usually what
you want when merging defaults with overrides.
Grouping
Object.groupBy and Map.groupBy are widely available now (Node 21+, and all
current browsers):
const byRole = Object.groupBy(users, (user) => user.role);
// { admin: [...], viewer: [...] }
Object.groupBy returns a null-prototype object, so byRole.hasOwnProperty
doesn’t exist. Use Object.hasOwn(byRole, key). The key also gets stringified,
so group by anything object-shaped with Map.groupBy instead.
The reduce version, for older runtimes:
const byRole = users.reduce((acc, user) => {
(acc[user.role] ??= []).push(user);
return acc;
}, {});
Sorting traps
const versions = [10, 9, 1];
versions.sort(); // [1, 10, 9], sorted as strings!
versions.sort((a, b) => a - b); // [1, 9, 10]
The default comparator converts everything to strings. This bites hardest on IDs and version numbers, where the result looks almost right.
sort also mutates in place and returns the same array, so this reorders the
prop you were handed:
const newest = props.items.sort(byDate)[0]; // props.items is now reordered
const newest = props.items.toSorted(byDate)[0]; // ES2023, non-mutating
const newest = [...props.items].sort(byDate)[0]; // works everywhere
toSorted, toReversed, toSpliced and with are the copying versions of the
mutating methods. arr.with(2, "x") is the one-line “replace index 2” you keep
writing by hand.
Sorting is stable in every modern engine, so multi-key sorting works by sorting on the least significant key first. Or just chain comparisons:
items.sort((a, b) => a.group.localeCompare(b.group) || b.score - a.score);
Use localeCompare for anything a human reads. Comparing by code point puts
"ä" after "z", and ["10", "9"].sort() needs
localeCompare(b, undefined, { numeric: true }) to sort the way people expect.
Holes, and why map sometimes does nothing
new Array(3); // [empty × 3]
new Array(3).map((_, i) => i); // [empty × 3], map skips holes!
Array.from({ length: 3 }, (_, i) => i); // [0, 1, 2]
Array.from is the reliable way to build a range. fill also works, with its
own trap: the value is evaluated once, so every slot shares one reference.
const grid = new Array(3).fill([]);
grid[0].push("x"); // grid is now [['x'], ['x'], ['x']]
const grid = Array.from({ length: 3 }, () => []); // three separate arrays
delete arr[1] leaves a hole rather than shortening the array. Use
splice(1, 1) or toSpliced(1, 1).
Async iteration
forEach ignores the promise your callback returns, so this logs “done” before
anything has happened:
items.forEach(async (item) => {
await save(item);
});
console.log("done"); // fires immediately
Run them in parallel, or in sequence, but pick one on purpose:
await Promise.all(items.map((item) => save(item))); // parallel, fails fast
for (const item of items) await save(item); // sequential
// Parallel, but you want every result including the failures
const results = await Promise.allSettled(items.map(save));
Odds and ends
arr.at(-1); // last element, no arr[arr.length - 1] dance
arr.filter(Boolean); // drop falsy values, but note 0 and '' go too
arr.flatMap((x) => x.tags); // map + flatten one level
arr.flat(Infinity); // fully flatten a nested array
arr.length = 0; // truncate in place, keeping the same reference
In TypeScript, filter(Boolean) doesn’t narrow the type. A type predicate does:
const defined = items.filter((item): item is Item => item != null);
And reduce without an initial value throws on an empty array, so pass the
initial value every time:
[].reduce((a, b) => a + b); // TypeError: Reduce of empty array
[].reduce((a, b) => a + b, 0); // 0