Skip to content

Array helpers ​

Eight collection helpers. Nothing mutates, and every selector is a function, which is what keeps the return type exact.

Open in demo
Open in demo

Import ​

ts
import { chunk, groupBy, keyBy, move, sortBy, sumBy, toggleItem, uniqueBy } from '@shared/utils/array'

groupBy ​

ts
function groupBy<T, K extends PropertyKey>(
  items: readonly T[],
  selector: (item: T, index: number) => K,
): Record<K, T[]>

Buckets items by a derived key.

ts
groupBy(users, u => u.role)

Only keys that occurred are present, so a literal-union K over-promises. Read through ?? [].

keyBy ​

ts
function keyBy<T, K extends PropertyKey>(
  items: readonly T[],
  selector: (item: T, index: number) => K,
): Record<K, T>

Indexes items by a derived key. Last one wins on a collision — the same rule Object.fromEntries uses.

ts
keyBy(users, u => u.id)['u-3']

uniqueBy ​

ts
function uniqueBy<T, K>(
  items: readonly T[],
  selector: (item: T, index: number) => K,
): T[]

Drops later items whose key was already seen. First one wins, so the result keeps the original order of first appearance.

ts
uniqueBy(rows, r => r.email)

sortBy ​

ts
function sortBy<T>(
  items: readonly T[],
  selector: (item: T) => Sortable,
  options?: SortByOptions,
): T[]

type Sortable = string | number | bigint | boolean | Date | null | undefined
OptionTypeDefaultDescription
direction'asc' | 'desc''asc'Nullish values stay last either way.
localestringthe active localeBCP-47 tag for the collator.
numericbooleantrueNatural ordering, so Item 9 precedes Item 10.
sensitivityIntl.CollatorOptions['sensitivity']'variant'Accents and case are significant.
ts
sortBy(users, u => u.name)
sortBy(invoices, i => i.total, { direction: 'desc' })

Strings go through Intl.Collator, never a < b, which would put Zurich before Ávila. It sorts a copy, it is stable, and nullish keys sort last in both directions — an absent value is not "smallest", it is missing.

Dates compare by time, booleans by Number(), and mixed types fall back to comparing their text through the same collator.

chunk ​

ts
function chunk<T>(items: readonly T[], size: number): T[][]
ts
chunk([1, 2, 3, 4, 5], 2) // [[1, 2], [3, 4], [5]]

size is coerced to at least 1. A size of zero would loop forever, and throwing inside a computed takes the page down.

sumBy ​

ts
function sumBy<T>(items: readonly T[], selector: (item: T, index: number) => number): number
ts
sumBy(lineItems, i => i.quantity * i.unitPrice)

Non-finite contributions are skipped, so one bad row cannot turn a dashboard total into NaN.

move ​

ts
function move<T>(items: readonly T[], from: number, to: number): T[]
ts
move(['a', 'b', 'c'], 0, 2) // ['b', 'c', 'a']

For drag-to-reorder. An out-of-range or non-integer index returns the list unchanged, rather than dropping or duplicating the item.

toggleItem ​

ts
function toggleItem<T>(
  items: readonly T[],
  item: T,
  isSame?: (a: T, b: T) => boolean,
): T[]

Adds the item if absent, removes it if present.

ts
toggleItem(selectedIds, 'u-3')
toggleItem(tags, tag, (a, b) => a.id === b.id)

Comparison defaults to Object.is, which is identity for objects. Pass a comparator for records.