Skip to content
WANCODE
BM

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[]>>.

groupBy.ts
/**
 * 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: T is the item type (inferred from your array) and K is the key type. PropertyKey means string | number | symbol — the things that can be object keys.
  • readonly T[] promises we won't modify your array, and lets you pass readonly arrays and tuples too.
  • If getKey returns "paid" | "pending", then K is exactly that union, so groups.paid autocompletes and groups.paidd is a type error.
  • Partial<Record<K, T[]>> admits that a group might be missing (no orders are pending today), so TypeScript makes you handle undefined. Plain Record<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 is null or undefined.
  • 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

TypeScript
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, ... }]