Array helpers
Eight collection helpers. Nothing mutates, and every selector is a function, which is what keeps the return type exact.
<script setup lang="ts">
import { chunk, groupBy, move, sortBy, sumBy, toggleItem, uniqueBy } from '@shared/utils/array'
import { Badge } from '@shared/components/ui/badge'
import { Button } from '@shared/components/ui/button'
import { Input } from '@shared/components/ui/input'
import { Label } from '@shared/components/ui/label'
import { computed, ref } from 'vue'
interface Invoice {
reference: string
customer: string
region: string
total: number
}
const INVOICES: Invoice[] = [
{ reference: 'INV-0418', customer: 'Ávila Foods', region: 'Iberia', total: 4820 },
{ reference: 'INV-0419', customer: 'Zurich Logistik', region: 'DACH', total: 1140 },
{ reference: 'INV-0420', customer: 'Åkerman AB', region: 'Nordics', total: 12300 },
{ reference: 'INV-0421', customer: 'Zurich Logistik', region: 'DACH', total: 640 },
{ reference: 'INV-0422', customer: 'Item 9 Ltd', region: 'Iberia', total: 2050 },
{ reference: 'INV-0423', customer: 'Item 10 Ltd', region: 'Iberia', total: 880 },
]
const direction = ref<'asc' | 'desc'>('asc')
const chunkSize = ref(2)
const selected = ref<string[]>(['INV-0418'])
const order = ref(INVOICES.map(i => i.reference))
const sorted = computed(() => sortBy(INVOICES, i => i.customer, { direction: direction.value }))
const grouped = computed(() => groupBy(INVOICES, i => i.region))
const customers = computed(() => uniqueBy(INVOICES, i => i.customer).map(i => i.customer))
const chunks = computed(() => chunk(INVOICES.map(i => i.reference), chunkSize.value))
const total = computed(() => sumBy(INVOICES, i => i.total))
function moveFirstDown(): void {
order.value = move(order.value, 0, 1)
}
</script>
<template>
<div class="flex flex-col gap-5">
<section class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<span class="sa-text-sm font-semibold">sortBy, through Intl.Collator</span>
<Button size="sm" variant="outline" @click="direction = direction === 'asc' ? 'desc' : 'asc'">
{{ direction }}
</Button>
</div>
<ol class="sa-text-sm text-muted-foreground">
<li v-for="invoice in sorted" :key="invoice.reference">
{{ invoice.customer }}
</li>
</ol>
</section>
<section class="flex flex-col gap-2">
<span class="sa-text-sm font-semibold">groupBy region</span>
<div class="flex flex-wrap gap-2">
<Badge v-for="(rows, region) in grouped" :key="region" variant="outline">
{{ region }} — {{ rows.length }}
</Badge>
</div>
</section>
<section class="flex flex-col gap-2">
<span class="sa-text-sm font-semibold">uniqueBy customer</span>
<p class="sa-text-sm text-muted-foreground">
{{ customers.join(' · ') }}
</p>
</section>
<section class="flex flex-col gap-2">
<div class="flex flex-wrap items-end gap-3">
<div class="grid gap-1">
<Label for="array-chunk">chunk size</Label>
<Input id="array-chunk" v-model.number="chunkSize" type="number" min="0" max="6" class="w-24" />
</div>
<p class="sa-text-sm text-muted-foreground">
{{ chunks.map(c => `[${c.join(', ')}]`).join(' ') }}
</p>
</div>
</section>
<section class="flex flex-col gap-2">
<span class="sa-text-sm font-semibold">toggleItem and sumBy</span>
<div class="flex flex-wrap gap-2">
<Button
v-for="invoice in INVOICES"
:key="invoice.reference"
size="sm"
:variant="selected.includes(invoice.reference) ? 'default' : 'outline'"
@click="selected = toggleItem(selected, invoice.reference)"
>
{{ invoice.reference }}
</Button>
</div>
<p class="sa-text-sm text-muted-foreground">
{{ selected.length }} selected · all invoices total {{ total.toLocaleString('en-GB') }}
</p>
</section>
<section class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<span class="sa-text-sm font-semibold">move</span>
<Button size="sm" variant="outline" @click="moveFirstDown()">
Move the first one down
</Button>
</div>
<p class="sa-text-sm text-muted-foreground">
{{ order.join(' → ') }}
</p>
</section>
</div>
</template>Import
import { chunk, groupBy, keyBy, move, sortBy, sumBy, toggleItem, uniqueBy } from '@shared/utils/array'groupBy
function groupBy<T, K extends PropertyKey>(
items: readonly T[],
selector: (item: T, index: number) => K,
): Record<K, T[]>Buckets items by a derived key.
groupBy(users, u => u.role)Only keys that occurred are present, so a literal-union K over-promises. Read through ?? [].
keyBy
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.
keyBy(users, u => u.id)['u-3']uniqueBy
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.
uniqueBy(rows, r => r.email)sortBy
function sortBy<T>(
items: readonly T[],
selector: (item: T) => Sortable,
options?: SortByOptions,
): T[]
type Sortable = string | number | bigint | boolean | Date | null | undefined| Option | Type | Default | Description |
|---|---|---|---|
direction | 'asc' | 'desc' | 'asc' | Nullish values stay last either way. |
locale | string | the active locale | BCP-47 tag for the collator. |
numeric | boolean | true | Natural ordering, so Item 9 precedes Item 10. |
sensitivity | Intl.CollatorOptions['sensitivity'] | 'variant' | Accents and case are significant. |
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
function chunk<T>(items: readonly T[], size: number): T[][]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
function sumBy<T>(items: readonly T[], selector: (item: T, index: number) => number): numbersumBy(lineItems, i => i.quantity * i.unitPrice)Non-finite contributions are skipped, so one bad row cannot turn a dashboard total into NaN.
move
function move<T>(items: readonly T[], from: number, to: number): T[]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
function toggleItem<T>(
items: readonly T[],
item: T,
isSame?: (a: T, b: T) => boolean,
): T[]Adds the item if absent, removes it if present.
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.
Related
- Object helpers — the same idea, one level down.
- Intl helpers — the collator
sortByshares. - DataTable — where most of this ends up.