Typed groupBy Utility (TypeScript)
Group an array into buckets with full type inference — and learn why the honest return type is Partial<Record<K, T[]>>.
/**
* Groups items into arrays by a key you compute.
*
* groupBy(users, (u) => u.state)
* -> { Selangor: [...], Sabah: [...] }
*/
export function groupBy<T, K extends PropertyKey>(
items: readonly T[],
getKey: (item: T, index: number) => K,
): Partial<Record<K, T[]>> {
// No prototype, so keys like "constructor" or "__proto__" are safe
const groups: Partial<Record<K, T[]>> = Object.create(null);
items.forEach((item, index) => {
const key = getKey(item, index);
(groups[key] ??= []).push(item);
});
return groups;
}
/** Same idea, but keeps any kind of key (objects, dates) and insertion order. */
export function groupByToMap<T, K>(
items: Iterable<T>,
getKey: (item: T) => K,
): Map<K, T[]> {
const groups = new Map<K, T[]>();
for (const item of items) {
const key = getKey(item);
const group = groups.get(key);
if (group) group.push(item);
else groups.set(key, [item]);
}
return groups;
}Grouping is one of the most common data jobs: orders by status, students by class, users by state. The logic is three lines. The interesting part is the types: TypeScript should know what's inside each group, and should be honest that some groups may not exist.
Reading the signature
<T, K extends PropertyKey>declares two generics:Tis the item type (inferred from your array) andKis the key type.PropertyKeymeansstring | number | symbol— the things that can be object keys.readonly T[]promises we won't modify your array, and lets you passreadonlyarrays and tuples too.- If
getKeyreturns"paid" | "pending", thenKis exactly that union, sogroups.paidautocompletes andgroups.paiddis a type error. Partial<Record<K, T[]>>admits that a group might be missing (no orders are pending today), so TypeScript makes you handleundefined. PlainRecord<K, T[]>would lie.(groups[key] ??= []).push(item)means: if this group doesn't exist yet, create an empty array; then push into it.??=only assigns when the left side isnullorundefined.Object.create(null)makes an object with no prototype, so a key called"constructor"doesn't collide with built-in properties.
There's a built-in now
Object.groupBy() and Map.groupBy() (ES2024) do the same job in Chrome 117+, Firefox 119+, Safari 17.4+ and Node.js 21+. TypeScript types them when your lib includes ES2024. Writing your own is still a great exercise — and works everywhere.
Object keys are always strings at runtime. If getKey returns numbers, groups[2024] works, but Object.keys(groups) gives "2024". Use groupByToMap when keys aren't strings.
Usage
import { groupBy, groupByToMap } from "./groupBy";
type Order = { id: number; status: "paid" | "pending" | "refunded"; total: number };
const orders: Order[] = [
{ id: 1, status: "paid", total: 45 },
{ id: 2, status: "pending", total: 12 },
{ id: 3, status: "paid", total: 80 },
];
const byStatus = groupBy(orders, (o) => o.status);
// ^? Partial<Record<"paid" | "pending" | "refunded", Order[]>>
const paidTotal = (byStatus.paid ?? []).reduce((sum, o) => sum + o.total, 0); // 125
const refunds = byStatus.refunded?.length ?? 0; // 0
const bySize = groupByToMap(orders, (o) => (o.total >= 50 ? "large" : "small"));
console.log(bySize.get("large")); // [{ id: 3, ... }]