Frontend / ECMAScript features / 05_modern_collection_methods.md

Modern Array and Object Methods

Updated 5 interview angles 6 min read source
On this page15
  1. Immutable array methods — show me.
  2. at() — what’s it for?
  3. findLast / findLastIndex.
  4. Object.groupBy and Map.groupBy (ES2024).
  5. Object.hasOwn — why over hasOwnProperty?
  6. structuredClone — proper deep clone.
  7. Array.fromAsync (ES2024).
  8. flat and flatMap.
  9. Map/Set extensions.
  10. Map.prototype.emplace — proposal, useful pattern.
  11. String modern methods.
  12. Gotchas
  13. Related
  14. Further reading
  15. Interview angle

Modern Array and Object Methods

The post-ES2020 additions to Array, Object, and Map that quietly simplify code. The biggest wins: immutable array methods (toSorted, toReversed, toSpliced, with — ES2023) that return a new array instead of mutating; at() for negative-index access; findLast/findLastIndex; Object.hasOwn (safer hasOwnProperty); Object.groupBy/Map.groupBy (ES2024); structuredClone for deep copy. Most senior wins from this list are about not mutating and not reaching for lodash when a native method exists.

Immutable array methods — show me.

ES2023 added non-mutating versions of sort, reverse, splice, and element replacement:

ts
const arr = [3, 1, 2];

// Mutating (old)
// mutates arr in place, returns the same array
arr.sort();
arr.reverse();
arr.splice(1, 1, 99);

// Immutable (new)
// arr unchanged
const sorted = arr.toSorted((a, b) => a - b);
const reversed = arr.toReversed();
const spliced = arr.toSpliced(1, 1, 99);
// replace index 0 with 99 → new array
const replaced = arr.with(0, 99);

These are the cleanest fix for the classic React bug:

tsx
// Bug — sort mutates state
const [items, setItems] = useState([3, 1, 2]);
// mutates! React may not re-render
items.sort();
setItems(items);

// Fix
setItems(items.toSorted());

For state libraries (Redux, Zustand), reducers must be immutable — toSorted removes the need for [...items].sort() workarounds.

at() — what’s it for?

Negative-index access without arr.length - n:

ts
const arr = [10, 20, 30];

arr.at(0);     // 10
arr.at(-1);    // 30 (last)
arr.at(-2);    // 20
arr[-1];       // undefined — bracket access doesn't accept negative

"hello".at(-1);   // "o"

Available on Array, String, TypedArrays. Cleaner than arr[arr.length - 1].

findLast / findLastIndex.

ES2023 — like find / findIndex but search from the end:

ts
const events = [
  { id: 1, type: "login" },
  { id: 2, type: "click" },
  { id: 3, type: "click" },
];

events.findLast(e => e.type === "click");   // { id: 3, type: "click" }
events.findLastIndex(e => e.type === "click");   // 2

Replaces the slow [...arr].reverse().find(...) pattern.

Object.groupBy and Map.groupBy (ES2024).

ts
const items = [
  { name: "apple", category: "fruit" },
  { name: "carrot", category: "vegetable" },
  { name: "banana", category: "fruit" },
];

Object.groupBy(items, (item) => item.category);
// {
//   fruit: [{ name: "apple", ... }, { name: "banana", ... }],
//   vegetable: [{ name: "carrot", ... }]
// }

Map.groupBy(items, (item) => item.category);
// Map { "fruit" => [...], "vegetable" => [...] }

Map.groupBy allows non-string keys (object keys preserve identity). Replaces _.groupBy from lodash for most cases.

Object.hasOwn — why over hasOwnProperty?

Object.prototype.hasOwnProperty can be shadowed (a property named hasOwnProperty on the object) or be inaccessible on objects created with Object.create(null) (no prototype):

ts
const obj1 = { hasOwnProperty: "foo" };
// TypeError — calls a string
obj1.hasOwnProperty("hasOwnProperty");

const obj2 = Object.create(null);
obj2.foo = 1;
// TypeError — no prototype
obj2.hasOwnProperty("foo");

// Safe — works on both
Object.hasOwn(obj1, "hasOwnProperty");     // true
Object.hasOwn(obj2, "foo");                 // true

Always prefer Object.hasOwn for own-property checks.

structuredClone — proper deep clone.

Built-in deep clone using the structured clone algorithm:

ts
const original = {
  name: "Ada",
  date: new Date(),
  nested: { items: [1, 2, 3] },
  map: new Map([["a", 1]]),
  set: new Set([1, 2]),
};

const copy = structuredClone(original);
copy.nested.items.push(99);
// [1, 2, 3] — unaffected
console.log(original.nested.items);

Handles:

  • Plain objects, arrays.
  • Date, RegExp.
  • Map, Set.
  • ArrayBuffer, typed arrays.
  • Circular references.
  • null / primitives.

Does NOT handle:

  • Functions (throws DataCloneError).
  • DOM nodes (throws).
  • Class instances lose their prototype (become plain objects).

For most app data, replaces JSON.parse(JSON.stringify(x)) (which loses Date, Map, Set, fails on circular refs).

Array.fromAsync (ES2024).

Collects an async iterable into an array:

ts
async function* fetchItems() {
  for (let i = 0; i < 3; i++) {
    yield await fetch(`/api/items/${i}`).then(r => r.json());
  }
}

const items = await Array.fromAsync(fetchItems());
// Waits for each, collects.

Equivalent of for await + push, but declarative. Useful with paginated APIs returning async generators.

flat and flatMap.

Older (ES2019) but underused:

ts
[[1, 2], [3, 4]].flat();              // [1, 2, 3, 4]
[[1, [2]], [3]].flat();               // [1, [2], 3] — depth 1
[[1, [2]], [3]].flat(2);              // [1, 2, 3]
[[1, 2], [3, 4]].flat(Infinity);      // fully flatten

// ["a", "b", "c", "d"]
["a b", "c d"].flatMap(s => s.split(" "));

flatMap is map(fn).flat(1) — common pattern: split each item into multiple.

Map/Set extensions.

ES2025 set operations:

ts
const a = new Set([1, 2, 3]);
const b = new Set([2, 3, 4]);

a.union(b);              // {1,2,3,4}
a.intersection(b);       // {2,3}
a.difference(b);         // {1}
a.symmetricDifference(b); // {1,4}
a.isSubsetOf(b);          // false
a.isSupersetOf(b);
a.isDisjointFrom(b);

Replaces the [...a].filter(x => b.has(x)) pattern. Modern Chrome/Edge/Safari/Firefox have it.

Map.prototype.emplace — proposal, useful pattern.

Stage 2 proposal for “get-or-insert”:

ts
// Today
const map = new Map<string, number[]>();
function push(k: string, v: number) {
  if (!map.has(k)) map.set(k, []);
  map.get(k)!.push(v);
}

// With emplace (proposed)
map.emplace(k, {
  insert: () => [],
  update: (existing) => { existing.push(v); return existing; },
});

Not yet stable; mention if you want to flex modern proposal awareness.

String modern methods.

Useful additions:

ts
"foo".at(-1);                    // "o"
"abc abc abc".replaceAll("a", "X");   // "Xbc Xbc Xbc"
"abc".padStart(5, "0");           // "00abc"
"abc".padEnd(5, "0");             // "abc00"
"  abc  ".trimStart();            // "abc  "
"  abc  ".trimEnd();
"abc".repeat(3);                  // "abcabcabc"

replaceAll is the big modern win — replaces the awkward /g-regex pattern for the common case.

Gotchas

  • Old sort/reverse mutate — easy to forget when working with state. The immutable variants prevent surprise mutations.
  • structuredClone on class instances loses class identity — they become plain objects (no instanceof MyClass).
  • structuredClone doesn’t clone DOM — throws. Use library-specific tools for DOM.
  • Object.groupBy is null-prototype object — no inherited methods. Use Map.groupBy if you need methods.
  • Array.fromAsync collects all before resolving — for infinite/large sequences, use for await instead.
  • at(-0) — same as at(0). Don’t expect -0 to mean “last.”
  • TypeScript lib target must include the right ES version for these methods to type-check. "lib": ["ES2024", "DOM"] for the recent ones.

Further reading

Interview angle 5

  • “How do you sort an array without mutating it?” - toSorted(), and likewise toReversed(), toSpliced() and with(i, v) (ES2023). They replace the [...arr].sort() dance and matter most in state management, where an accidental in-place sort mutates the state object you were about to compare.
  • “What replaced the awkward index arithmetic?” - at(-1) for the last element instead of arr[arr.length - 1], and findLast/findLastIndex instead of reversing a copy to search from the end.
  • “Why prefer Object.hasOwn over hasOwnProperty?” - because obj.hasOwnProperty breaks on an object with a null prototype and can be shadowed by a property of that name. Object.hasOwn(obj, key) is a static call that neither problem reaches.
  • “How do you deep-clone an object?” - structuredClone, which handles Date, Map, Set, typed arrays and circular references. It cannot clone functions, DOM nodes or class identity, so it throws rather than silently producing something wrong.
  • “What can you drop lodash for now?” - Object.groupBy and Map.groupBy cover most groupBy use, and Set gained union, intersection and difference. The remaining reasons to keep it are the deep-object helpers.
    • Follow-up: “Why does TypeScript say these do not exist?” - the lib setting in tsconfig is behind the runtime. Runtime support is broad; you have to raise lib to the matching ES version for them to type-check.