Introduction
We started our first post of this series with a simple problem: given some orders, group them by customer, keep the customers with more than one order, work out how much they spent, and sort highest first. The point back then was how much of each version was business logic and how much was ceremony. There was an assumption baked into all four that we never said out loud: the orders arrived as a list.
In a real system, they often don't. An upstream deduplication step hands you a Set because it has already collapsed retries. A cache or a repository hands you a Map keyed by order id, because that's how everything else in that module looks things up. A database driver hands you something lazy that you'd rather not pull into memory all at once. Same orders, same aggregation, but three or four different data structures holding them.
So: does topRepeatCustomers keep working when the container changes, or do you end up with a List version, a Set version and a Map version of the same six lines? Can one function work across all of them while still using map, filter and reduce? That is what this post is about.
Python: iteration is all they share
Here is the Python version from that first post, adjusted slightly so orders are a real class rather than a dict, and with the two bookkeeping loops merged into one:
from collections import defaultdict
from dataclasses import dataclass
@dataclass(frozen=True)
class Order:
id: str
customer: str
amount: float
def top_repeat_customers(orders):
totals = defaultdict(float)
counts = defaultdict(int)
for order in orders:
totals[order.customer] += order.amount
counts[order.customer] += 1
repeat = {c: t for c, t in totals.items() if counts[c] > 1}
return sorted(repeat.items(), key=lambda kv: kv[1], reverse=True)Now, let's hand it the other containers. The good news is that list, set and dict do share exactly one thing: they are all iterable, so the for loop keeps working. The bad news is what "iterable" means for a dict.
orders_list = [
Order("o1", "acme", 120.0),
Order("o2", "acme", 80.0),
Order("o3", "globex", 200.0),
]
orders_set = set(orders_list)
orders_by_id = {o.id: o for o in orders_list}
top_repeat_customers(orders_list) # fine
top_repeat_customers(orders_set) # fine, iterating a set yields Orders
# Iterating a dict walks its KEYS ("o1", "o2", "o3"), not its Order values,
# so this blows up on order.customer with an AttributeError at runtime.
top_repeat_customers(orders_by_id)
top_repeat_customers(orders_by_id.values()) # what you actually needThe problem is when and where that failure lands: at runtime, on whichever call site runs first, and only if that path runs at all. Annotating the parameter as Iterable[Order] helps, and you should, but as we have said before, that is a hint for a type checker somebody has to configure and run. The language enforces nothing.
Which raises the question of why the function is a loop plus a free function in the first place. None of the three containers has a .map, .filter or .fold to chain. Anything past plain iteration has to come from somewhere else:
from functools import reduce
from itertools import groupby
# map / filter / reduce are free functions or builtins, called with the
# container as an argument, never as a fluent call on it.
total = reduce(lambda acc, o: acc + o.amount, filter(lambda o: o.amount > 0, orders_list), 0.0)
# itertools.groupby is NOT the groupBy you want: it only groups ADJACENT
# equal keys, so the input has to be sorted by the same key first, and each
# group it yields is a one-shot iterator that is consumed as you move on.
by_customer = {c: list(os) for c, os in groupby(sorted(orders_list, key=lambda o: o.customer),
key=lambda o: o.customer)}
# sorted() is a builtin too, and it always returns a list, whatever went in.
ranked = sorted(by_customer.items(), key=lambda kv: kv[0])Three containers, and the vocabulary for working with them lives in three different places: builtins (sum, sorted, map), a module (functools, itertools), and comprehension syntax that changes meaning based on punctuation, since {o.id: o for o in orders} is a dict comprehension and {o for o in orders} is a set comprehension, distinguished only by whether a colon shows up inside the braces.
TypeScript: Array gets the methods, Map and Set don't
TypeScript starts from a better place: Array.prototype ships with map, filter and reduce natively, and they chain pleasantly. That's what the version in the first post leaned on:
type Order = { id: string; customer: string; amount: number };
function topRepeatCustomers(orders: Order[]): [string, number][] {
const byCustomer = new Map<string, Order[]>();
for (const order of orders) {
const list = byCustomer.get(order.customer) ?? [];
list.push(order);
byCustomer.set(order.customer, list);
}
return [...byCustomer.entries()]
.filter(([, os]) => os.length > 1)
.map(([c, os]) => [c, os.reduce((sum, o) => sum + o.amount, 0)] as [string, number])
.sort((a, b) => b[1] - a[1]);
}Look at what happens the moment the input isn't an array: the parameter type says Order[] so, for example, a Set<Order> won't typecheck at all. We can circumvent this easily by widening topRepeatCustomers to accept an Iterable<Order>: TypeScript does have a shared iteration protocol, and Array, Set, Map and generators all implement it.
All that buys us is a simpler derivation of byCustomer. Because TypeScript puts map, filter and reduce on Array only, we still spread the byCustomer map back out into an array before we can chain anything on it:
function topRepeatCustomers(orders: Iterable<Order>): [string, number][] {
// Map.groupBy (ES2024) does take any iterable, which is a real improvement.
// What it hands back, though, is a Map, and Map has no .filter and no .map,
// so the very next line spreads straight back out into an array.
const byCustomer = Map.groupBy(orders, (o) => o.customer);
return [...byCustomer.entries()]
.filter(([, os]) => os.length > 1)
.map(([c, os]) => [c, os.reduce((sum, o) => sum + o.amount, 0)] as [string, number])
.sort((a, b) => b[1] - a[1]);
}
const ordersList: Order[] = [
{ id: "o1", customer: "acme", amount: 120 },
{ id: "o2", customer: "acme", amount: 80 },
{ id: "o3", customer: "globex", amount: 200 },
];
const ordersSet = new Set(ordersList);
const ordersById = new Map(ordersList.map((o) => [o.id, o]));
topRepeatCustomers(ordersList); // ok
topRepeatCustomers(ordersSet); // ok, Set<Order> is an Iterable<Order>
topRepeatCustomers(ordersById); // compile error, see below
topRepeatCustomers(ordersById.values()); // okThe ordersById line is at least a compile error rather than Python's runtime surprise, since a Map<string, Order> is an Iterable<[string, Order]>, not an Iterable<Order>. Past that improvement the ergonomics stay awkward. Every pipeline over a Set or a Map spreads into an array, and if you want that container type back at the end you rewrap into a new Set(...) or new Map(...). The ceremony is accidental: nothing about a Set makes .filter harder to implement on it than on an Array. The standard library simply never shipped one.
Java: a uniform API, one bridge away, still clunky
Java's collections, List, Set, Map and the rest, have no map, filter or reduce of their own. Since Java 8, every one of them can reach a uniform API through .stream(), and the pipeline itself is identical no matter what you started with, which is more than either language above can say. The catch is the bridge you cross to get there.
record Order(String id, String customer, double amount) {}
LinkedHashMap<String, Double> topRepeatCustomers(Collection<Order> orders) {
Map<String, List<Order>> byCustomer = orders.stream()
.collect(Collectors.groupingBy(Order::customer));
return byCustomer.entrySet().stream()
.filter(e -> e.getValue().size() > 1)
.collect(Collectors.toMap(
Map.Entry::getKey,
e -> e.getValue().stream().mapToDouble(Order::amount).sum()))
.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.collect(Collectors.toMap(
Map.Entry::getKey, Map.Entry::getValue,
(a, b) -> a, LinkedHashMap::new));
}Three .stream() calls, three .collect(...) calls to get back off, and a LinkedHashMap::new factory supplied by hand because Collectors.toMap would otherwise return a HashMap and throw away the ordering we just worked so hard for. The container you end up with is always an explicit argument here, never something inferred from the container you began with.
Now the call sites:
List<Order> ordersList = List.of(
new Order("o1", "acme", 120.0),
new Order("o2", "acme", 80.0),
new Order("o3", "globex", 200.0));
Set<Order> ordersSet = new HashSet<>(ordersList);
Map<String, Order> ordersById = ordersList.stream()
.collect(Collectors.toMap(Order::id, o -> o));
topRepeatCustomers(ordersList); // ok
topRepeatCustomers(ordersSet); // ok, Set IS a Collection
topRepeatCustomers(ordersById); // does not compile: Map is NOT a Collection
topRepeatCustomers(ordersById.values()); // ok, values() IS a Collection<Order>
// And if you widen the parameter to Iterable<Order> to accept lazily-produced
// sources too, you lose .stream() entirely, because Iterable doesn't provide itTwo things are worth mentioning. First, Java's Map deliberately sits outside its Collection hierarchy, so we must use .values() to get a Collection we can actually work on. Second, the uniformity Java does offer lives on a second type that none of the collections themselves are. Every pipeline pays the toll of getting on and off, and if you widen a signature one step past Collection, the toll booth disappears along with the road.
Scala: the same Iterable, all the way down
In Scala, List, Set, Vector, LazyList, Queue and the rest all implement the same Iterable[A] hierarchy, and map, filter, groupBy and fold are defined once, on that shared hierarchy, rather than reimplemented per container. There is no second type to bridge onto, because the collections are the type carrying the API. (That is the traits machinery from an earlier post, doing exactly what traits are for.)
case class Order(id: String, customer: String, amount: Double)
def topRepeatCustomers(orders: Iterable[Order]): List[(String, Double)] =
orders
.groupBy(_.customer)
.filter(_._2.size > 1)
.map { case (customer, os) => customer -> os.iterator.map(_.amount).sum }
.toList
.sortBy(-_._2)Read the middle of that chain: groupBy is called on an Iterable[Order] and returns a Map[String, Iterable[Order]], and then filter and map are called on the Map, with the same names and the same shapes they have on a List. No entrySet().stream(), no spread into an array, no .values() in sight. And every container reaches it with (almost) no conversion:
val ordersList: List[Order] =
List(Order("o1", "acme", 120.0), Order("o2", "acme", 80.0), Order("o3", "globex", 200.0))
val ordersSet: Set[Order] = ordersList.toSet
val ordersLazy: LazyList[Order] = ordersList.to(LazyList)
val ordersById: Map[String, Order] = ordersList.map(o => o.id -> o).toMap
topRepeatCustomers(ordersList) // List[Order] IS an Iterable[Order]
topRepeatCustomers(ordersSet) // so is Set[Order]
topRepeatCustomers(ordersLazy) // so is LazyList[Order], evaluated on demand
// This time, Map[String, Order] IS an Iterable, but of the type (String, Order), not Order,
// so this line will still produce a compiler failure, but for a different reason than Java
topRepeatCustomers(ordersById)
// However, we can use .values here to get an Iterable[Order], and the rest will follow
topRepeatCustomers(ordersById.values)Note that the Map case is the same distinction TypeScript's compiler flagged and Python's runtime didn't: a map of orders is a collection of pairs, and pretending otherwise is a type error. TypeScript and Scala both catch this issue at compile time, but the difference is that fixing it in Scala costs you one function, .values, rather than a different way of writing the whole function.
The result follows the container
Scala's uniformity goes past method names. With few exceptions (reduce, folding and grouping operations are the most known), calling a method on a collection will give you back the same type of collection, without need to use utilities such as Collectors.toSet()to rewrap the intermediate results:
List(1, 2, 3).map(_ * 2) // List(2, 4, 6)
Set(1, 2, 3).map(_ * 2) // Set(2, 4, 6)
Vector(1, 2, 3).map(_ * 2) // Vector(2, 4, 6)
Map("a" -> 1, "b" -> 2).map { case (k, v) => k -> v * 2 } // Map(a -> 2, b -> 4)Which means, you need to be mindful of the functions you're applying. Can you guess why we wrote os.iterator.map(...).sum in our topRepeatCustomers function, rather than just os.map(...).sum ?
Try to think about the implications of passing collection that is an Iterable, then expand the solution below to see if you got it right :)
Solution
We did it to handle properly a Set[Order]. What happens if we have multiple orders that contain the same amount ? Well ... the moment we extract their amount by maping over each order, we obtain a Set[Double], therefore discarding duplicate amounts.
val acme = Set(Order("o1", "acme", 50.0), Order("o2", "acme", 50.0))
acme.map(_.amount).sum // 50.0 <- Set[Double] collapsed the two equal amounts
acme.iterator.map(_.amount).sum // 100.0 <- what the aggregation actually meant
acme.view.map(_.amount).sum // 100.0 <- same idea, lazilyPlease don't make the mistake to assume the API is being inconsistent here, because it is, in fact, being faithful to what its method over the collection does: mapping values of a Set produces another Set. When you want the intermediate step to be a plain sequence of values rather than a rebuilt container, just use .iterator or .view, which are available on every collection in the library.
One pipeline, not one per container
Every non-Scala version above needed container-specific glue before the actual business logic could run: .values() in Python, a spread and rewrap in TypeScript, .stream() plus a matching Collectors.toX() in Java. That glue is exactly the sort of thing an AI agent gets subtly wrong when asked to make a working pipeline accept a different container. Swap a list for a dict in a Python function and iterating directly still runs, still type-checks as far as the interpreter cares, then throws at runtime.
There is a token cost too, compounding the one from that first post. An agent working in Java holds two vocabularies in context, the Collections API and the Streams API, plus the Collectors catalogue joining them. In Python it is builtins, functools, itertools and three comprehension syntaxes. In Scala, map is map on everything, and topRepeatCustomers never needed a Set version or a Map version. It was written once and the containers came to it. No per-container copy for anyone, human or agent, to keep in sync, and no bridging step to forget or to get subtly wrong.
