- Book: TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open the analytics service. Somewhere in there is a function called bucketByStatus. It is fourteen lines. It declares an empty object, walks an array of orders, and pushes each order into a key on the object based on its status. The first time you see it, you read the whole thing to be sure. The next time, you skim. The third time, you write a copy of it three files away because finding the original took longer than rewriting the body.
A team you talk to has six functions like this in one repo. None of them is wrong. None of them is interesting. Every one of them is a reduce with an accumulator object, an if for "have I seen this key yet," and a push.
That whole pattern was retired in ES2024. Two static methods replaced it: Object.groupBy and Map.groupBy. Both reached TC39 Stage 4 since 2024 and landed in the ECMAScript 2024 spec. They are now Baseline 2024, with runtime support per each runtime's release notes: Node 21+, Bun 1.1+, Deno 1.38+, Chrome 117+, Edge 117+, Safari 17.4+, and Firefox 119+. If you have a TypeScript service in production today, your runtime almost certainly has them.
The rule about Map.groupBy versus Object.groupBy that the docs do not spell out is in pattern 2 below.
The shape, in two lines
Both methods take a list and a key function. They return a structure where the keys are what your callback returned and the values are arrays of the inputs that returned that key.
const orders = await fetchOrders();
const byStatus = Object.groupBy(orders, (o) => o.status);
// type: Partial<Record<string, Order[]>>
const byCustomer = Map.groupBy(orders, (o) => o.customer);
// type: Map<Customer, Order[]>
That is the entire surface. No accumulator. No "have I seen this key" check. No cast. The compiler reads the return type of the callback and threads it through.
The thing the docs leave implicit: pick Object.groupBy when your key is a string or a number that reads cleanly as a string. Pick Map.groupBy when your key is anything else. That includes object identity, Date instances, branded IDs, tuples used as composite keys, and any time you care about insertion order. The rest of this post is the long version of that rule.
Pattern 1: orders by status, the hand-rolled reduce versus Object.groupBy
This is the case Object.groupBy was designed for. The key is a string literal that is part of your domain type, and the result is a dictionary you hand to a renderer.
The version everybody has shipped at least once:
type OrderStatus = "pending" | "paid" | "shipped" | "refunded";
type Order = {
id: string;
total: number;
status: OrderStatus;
};
function groupByStatusOld(
orders: Order[],
): Record<OrderStatus, Order[]> {
const acc = {
pending: [],
paid: [],
shipped: [],
refunded: [],
} as Record<OrderStatus, Order[]>;
for (const o of orders) {
acc[o.status].push(o);
}
return acc;
}
A few problems with this. The empty-bucket initialization duplicates the union type. Add "cancelled" to OrderStatus and this function compiles fine until production. The cast on the literal is the kind of as that lies. The mutating push makes the function awkward to use in a streaming pipeline.
The Object.groupBy version:
function groupByStatus(orders: Order[]) {
return Object.groupBy(orders, (o) => o.status);
}
// inferred return:
// Partial<Record<OrderStatus, Order[]>>
The compiler narrows the key type to OrderStatus automatically, because the callback returns OrderStatus. The return type wraps in Partial<...> because the spec is honest: if no order is "refunded", the refunded key is missing, not an empty array. You handle that at the read site:
const grouped = groupByStatus(orders);
const refunds = grouped.refunded ?? [];
The ?? [] is the price of correctness. The Record<OrderStatus, Order[]> you wrote before was lying about empty groups: it always claimed to have a key, even when it did not. The new shape forces you to acknowledge the gap once and move on.
When the union grows, nothing else changes. Add "cancelled" to OrderStatus and the callback's inferred type widens, the return type widens, and any downstream grouped.cancelled access becomes Order[] | undefined like the rest. No empty-bucket constructor to maintain. No cast to remove.
Pattern 2: time windows keyed on Date, where Map.groupBy is the only sane answer
The moment your key is not a primitive, Object.groupBy is the wrong tool. Object keys coerce to strings. Two Date objects with the same wall-clock time stringify to the same key, even though they are different objects. Two Date objects that should group together (same hour, different minute) stringify to different keys. You end up serializing the date into a string and grouping on the string, which is fine, but you have lost the typed Date everywhere downstream.
Map.groupBy keeps the key as a real value with a real type.
type Event = {
id: string;
occurredAt: Date;
payload: unknown;
};
function bucketHourly(events: Event[]): Map<Date, Event[]> {
return Map.groupBy(events, (e) => {
const d = new Date(e.occurredAt);
d.setMinutes(0, 0, 0);
return d;
});
}
The callback returns a Date rounded down to the hour. The result is Map<Date, Event[]>. Because Map uses SameValueZero equality for keys, two Date instances with the same valueOf() collide into the same bucket. That is what you want.
You iterate the map in insertion order, which Object.groupBy does not give you:
for (const [hour, batch] of bucketHourly(events)) {
await flushBatch(hour, batch);
}
The for...of walks buckets in the order their first event appeared. If the events arrived chronologically, the buckets come out chronologically. With Object.groupBy you would have had to call Object.keys, sort them as strings (which fails for any date format that does not sort lexicographically), and reread the buckets. The Map version is one line.
The same rule applies to any composite or non-string key:
type CartLine = {
productId: string;
variantId: string;
qty: number;
};
const byProductVariant = Map.groupBy(
lines,
(l) => `${l.productId}::${l.variantId}` as const,
);
You can do this with Object.groupBy too; the template literal is a string. The Map version wins when iteration order matters, or when you want to key on a non-string composite (a stable cached object or tuple) and look it up by reference. For a one-shot read-and-render, either one is fine.
Pattern 3: discriminated-union grouping with type narrowing
The pattern that surprises people the first time they see it: when you group a discriminated union by its discriminant, the compiler narrows each bucket to the matching variant.
type Notification =
| { kind: "email"; to: string; subject: string }
| { kind: "sms"; phone: string; body: string }
| { kind: "push"; deviceId: string; title: string };
function fanOut(items: Notification[]) {
const byKind = Object.groupBy(items, (n) => n.kind);
// byKind.email : Notification[] | undefined
}
TS may widen the key in some inference paths — verify with your version. When the variant narrowing is not preserved on the buckets, an explicit mapped type makes the intent unambiguous and the loop body sees the narrow type:
function fanOut(items: Notification[]) {
const byKind = Object.groupBy(
items,
(n) => n.kind,
) as {
[K in Notification["kind"]]?:
Extract<Notification, { kind: K }>[];
};
for (const e of byKind.email ?? []) {
// e is { kind: "email"; to: string; subject: string }
await sendEmail(e.to, e.subject);
}
for (const s of byKind.sms ?? []) {
// s is { kind: "sms"; phone: string; body: string }
await sendSMS(s.phone, s.body);
}
}
The as is the kind of cast that pays for itself: you are telling the compiler what the structure is, in a shape it could not always infer from the runtime signature alone. The mapped-type expression on the right is the work: Extract<Notification, { kind: K }> pulls each variant out of the union and the loop body sees the narrow type. No if (e.kind === "email") guard inside the body. The grouping did the narrowing.
This is the version of the pattern the TC39 proposal gestures at. The runtime alone cannot deliver it; the static typing layer is what makes the pattern earn its keep at scale. Stash the helper in a lib/ once and import it everywhere:
type GroupByDiscriminant<
T extends { [K in D]: string },
D extends keyof T,
> = {
[K in T[D] & string]?: Extract<T, Record<D, K>>[];
};
export function groupByKind<
T extends { [K in D]: string },
D extends keyof T,
>(items: T[], discriminant: D): GroupByDiscriminant<T, D> {
return Object.groupBy(
items,
(i) => i[discriminant] as string,
) as GroupByDiscriminant<T, D>;
}
Now groupByKind(notifications, "kind").email is typed as Email[] | undefined everywhere it is read. The as lives in one place and is reviewed once.
The polyfill story for the older runtime
If you are pinned to Node 18 (security-supported until April 2025, out of LTS now) or an older Bun, the methods are missing. There are three reasonable answers.
The first is to upgrade. Node 21+ supports it natively, so any current LTS line works. If the only thing keeping you on 18 is inertia, the upgrade is cheaper than the workarounds.
Option two: the core-js polyfill. core-js ships both Object.groupBy and Map.groupBy and matches the spec. Add it as the polyfill entry in your build, or import the targeted entries directly:
import "core-js/actual/object/group-by";
import "core-js/actual/map/group-by";
Once the polyfill loads, the method is on the global. The TypeScript types come from lib.es2024.object.d.ts and lib.es2024.collection.d.ts, so you also need "target": "es2024" (or "lib": ["es2024.object", "es2024.collection"] if you cannot move the target).
Option three is the four-line shim. This is a groupBy library function under another name. It is not the static method, and any library type definitions that touch Object.groupBy will not see it.
function groupBy<T, K extends PropertyKey>(
items: Iterable<T>,
key: (item: T) => K,
): Partial<Record<K, T[]>> {
const out: Partial<Record<K, T[]>> = {};
for (const item of items) {
const k = key(item);
(out[k] ??= []).push(item);
}
return out;
}
Use the shim only as a stopgap. The whole point of Object.groupBy is that everyone reaching for "group these by status" finds the same method, with the same signature, in the same namespace. A bespoke groupBy in your utils.ts is what made the original problem a problem.
When neither method is the answer
There is one shape that looks like grouping and is not. If you only want a single value per key (a Map<id, Item> for fast lookup, not Map<id, Item[]>), Map.groupBy gives you the wrong type. You wanted new Map(items.map((i) => [i.id, i])), which is one line and an existing primitive.
The other case: when the result of the group is an aggregate, not a list. Counts, sums, max, the kind of thing SQL GROUP BY ... HAVING SUM(...) does. Object.groupBy returns the lists; you compose with the array methods on the way out:
const totals = Object.fromEntries(
Object.entries(Object.groupBy(orders, (o) => o.status))
.map(([k, list]) => [
k,
(list ?? []).reduce((s, o) => s + o.total, 0),
]),
) as Record<OrderStatus, number>;
Note: Object.fromEntries(Object.entries(...)) widens the literal-key union back to string, so cast at the call site (as Record<OrderStatus, number>) or wrap with a typed fromEntries shim. That works and is fine for medium arrays. For ten million rows, push the aggregation to your database. The methods are for the data already in memory.
The next time you find yourself typing const result: Record<...> = {} followed by for (const item of items), stop. There is a method for that now, and it has been on every runtime you ship to for two years.
If this was useful
This is one of the runtime-meets-type-system patterns TypeScript Essentials spends time on. The book's posture is that TypeScript in 2026 is not a thin layer of types over JavaScript — it is the type-aware way to write JavaScript that survived a decade of language evolution. The chapters on iteration, narrowing, and discriminated unions are where Object.groupBy, Map.groupBy, and the iterator helpers slot in.
If you have ever had to explain to a JVM dev why TypeScript does not have sealed classes (and why discriminated unions are the better answer anyway), or to a PHP 8+ dev why the type system carries narrowing through array methods that PHP cannot, the bridge books cover that ground from the source language out.
The five-book set:
- TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
- The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
- Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
- PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
- TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471
All five books ship in ebook, paperback, and hardcover.

Top comments (0)