← Back to the journal

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