An assembly line for data
Think of a factory assembly line. Items move along the belt, and each station does one job: one removes damaged items, the next paints each item, the last packs everything into one box.
Functional operations are those stations:
filterkeeps only the items that pass a test (remove the damaged ones).mapturns each item into something else (paint each one).reduce(orsum,fold) combines all items into one result (pack them into one box).
You chain them, and each one gives the next a new collection. The original collection is never changed.
The four languages share a vocabulary built on higher-order functions over collections, but differ in evaluation: Swift’s Sequence/Collection methods, Kotlin’s Iterable extensions and TypeScript’s array methods are eager (each step builds a new array or list), while Java streams are lazy, single-use pipelines with a terminal operation. Swift’s .lazy and Kotlin’s Sequence opt into laziness. The other differences that matter are mutability (Swift value types, Kotlin read-only views, Java unmodifiable vs immutable), boxing of primitives, and behavior on edge cases like empty collections and duplicate keys. The Detailed version covers them.
struct Order {
let customer: String
let total: Double
let isPaid: Bool
}
let topCustomers = Dictionary(grouping: orders.filter(\.isPaid), by: \.customer) // customer -> [Order]
.mapValues { orders in orders.reduce(0) { $0 + $1.total } } // customer -> revenue
.sorted { $0.value > $1.value } // highest first
.prefix(3) // top three
for (customer, revenue) in topCustomers {
print(customer, revenue)
}data class Order(val customer: String, val total: Double, val isPaid: Boolean)
val topCustomers = orders
.filter { it.isPaid }
.groupBy { it.customer } // customer -> List<Order>
.mapValues { (_, orders) -> orders.sumOf { it.total } } // customer -> revenue
.entries
.sortedByDescending { it.value } // highest first
.take(3) // top three
topCustomers.forEach { (customer, revenue) -> println("$customer $revenue") }record Order(String customer, double total, boolean isPaid) {}
List<Map.Entry<String, Double>> topCustomers = orders.stream()
.filter(Order::isPaid)
.collect(Collectors.groupingBy(Order::customer,
Collectors.summingDouble(Order::total))) // customer -> revenue
.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed()) // highest first
.limit(3) // top three
.toList();
topCustomers.forEach(e -> System.out.println(e.getKey() + " " + e.getValue()));interface Order {
customer: string;
total: number;
isPaid: boolean;
}
const byCustomer = Map.groupBy(orders.filter((o) => o.isPaid), (o) => o.customer); // customer -> Order[]
const topCustomers = [...byCustomer]
.map(([customer, orders]) => [customer, orders.reduce((sum, o) => sum + o.total, 0)] as const) // revenue
.toSorted((a, b) => b[1] - a[1]) // highest first
.slice(0, 3); // top three
for (const [customer, revenue] of topCustomers) console.log(customer, revenue);Read each version from top to bottom, like the sentence: keep paid orders, group them by customer, add up each group, sort from highest, take three. Each step has one job, and none of them changes orders.
Each language says it slightly differently. Swift, Kotlin and TypeScript call the operations directly on the collection (TypeScript’s Map.groupBy is new in ES2024). Java first turns it into a stream with .stream(), and ends with a collector (collect, toList) that builds the result.
The operations you’ll use every week
let byDay = Dictionary(grouping: messages) { Calendar.current.startOfDay(for: $0.sentAt) } // group
let byID = Dictionary(uniqueKeysWithValues: messages.map { ($0.id, $0) }) // index by key
let (unread, read) = (messages.filter { !$0.isRead }, messages.filter(\.isRead)) // split
let allTags = messages.flatMap(\.tags) // flatten
let hasUrgent = messages.contains { $0.priority == .urgent } // any?
let newest = messages.max { $0.sentAt < $1.sentAt } // max by
let pairs = zip(messages, messages.dropFirst()) // neighborsval byDay = messages.groupBy { it.sentAt.toLocalDate() } // group
val byId = messages.associateBy { it.id } // index by key
val (unread, read) = messages.partition { !it.isRead } // split
val allTags = messages.flatMap { it.tags } // flatten
val hasUrgent = messages.any { it.priority == Priority.URGENT } // any?
val newest = messages.maxByOrNull { it.sentAt } // max by
val pairs = messages.zipWithNext() // neighborsMap<LocalDate, List<Message>> byDay = messages.stream()
.collect(Collectors.groupingBy(m -> m.sentAt().toLocalDate())); // group
Map<Long, Message> byId = messages.stream()
.collect(Collectors.toMap(Message::id, m -> m)); // index by key
Map<Boolean, List<Message>> readOrNot = messages.stream()
.collect(Collectors.partitioningBy(Message::isRead)); // split
List<String> allTags = messages.stream().flatMap(m -> m.tags().stream()).toList(); // flatten
boolean hasUrgent = messages.stream().anyMatch(m -> m.priority() == Priority.URGENT); // any?
Optional<Message> newest = messages.stream().max(Comparator.comparing(Message::sentAt)); // max byconst byDay = Map.groupBy(messages, (m) => m.sentAt.toDateString()); // group
const byId = new Map(messages.map((m) => [m.id, m])); // index by key
const unread = messages.filter((m) => !m.isRead); // split (two filters)
const read = messages.filter((m) => m.isRead);
const allTags = messages.flatMap((m) => m.tags); // flatten
const hasUrgent = messages.some((m) => m.priority === "urgent"); // any?
const newest = messages.reduce((a, b) => (b.sentAt > a.sentAt ? b : a)); // max by (throws if empty)Practice: reading a transformation step by step
Here’s a real request: “For the dashboard, show revenue per product category for the last 30 days, top five categories, as percentages of the total.” Let’s build it one station at a time.
- Filter the order lines to the last 30 days.
- Group them by category, and sum each group’s revenue (price × quantity).
- Compute the total once, from the grouped sums.
- Sort categories by revenue, descending, and take five.
- Map each to a percentage of the total.
let since = Date.now.addingTimeInterval(-30 * 24 * 3600)
let revenueByCategory = Dictionary(grouping: lines.filter { $0.date >= since }, by: \.category)
.mapValues { $0.reduce(0) { $0 + $1.price * Double($1.quantity) } }
let total = revenueByCategory.values.reduce(0, +)
let top = revenueByCategory
.sorted { $0.value > $1.value }
.prefix(5)
.map { (category: $0.key, percent: total > 0 ? $0.value / total * 100 : 0) }val since = LocalDate.now().minusDays(30)
val revenueByCategory = lines
.filter { it.date >= since }
.groupBy { it.category }
.mapValues { (_, items) -> items.sumOf { it.price * it.quantity } }
val total = revenueByCategory.values.sum()
val top = revenueByCategory.entries
.sortedByDescending { it.value }
.take(5)
.map { (category, revenue) -> category to if (total > 0) revenue / total * 100 else 0.0 }LocalDate since = LocalDate.now().minusDays(30);
Map<String, Double> revenueByCategory = lines.stream()
.filter(l -> !l.date().isBefore(since))
.collect(Collectors.groupingBy(OrderLine::category,
Collectors.summingDouble(l -> l.price() * l.quantity())));
double total = revenueByCategory.values().stream().mapToDouble(Double::doubleValue).sum();
List<Map.Entry<String, Double>> top = revenueByCategory.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(5)
.map(e -> Map.entry(e.getKey(), total > 0 ? e.getValue() / total * 100 : 0.0))
.toList();const since = Date.now() - 30 * 24 * 3600 * 1000;
const revenueByCategory = new Map<string, number>();
for (const line of lines.filter((l) => l.date.getTime() >= since)) {
revenueByCategory.set(line.category, (revenueByCategory.get(line.category) ?? 0) + line.price * line.quantity);
}
const total = [...revenueByCategory.values()].reduce((sum, r) => sum + r, 0);
const top = [...revenueByCategory]
.toSorted((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([category, revenue]) => ({ category, percent: total > 0 ? (revenue / total) * 100 : 0 }));Notice the total > 0 check: the “no orders in 30 days” case would otherwise divide by zero and show NaN% on the dashboard. Edge cases (empty input, a single item, ties) are where functional code needs the same care as loops.
Top words
Write topWords(text, n) that returns the n most frequent words in a text, with their counts, most frequent first. Words are case-insensitive, punctuation doesn’t count, and ties are broken alphabetically.
topWords("The cat and the hat. The END, the end!", 2)
→ [("the", 4), ("end", 2)]›
First normalize: lowercase, then split on anything that isn’t a letter, and drop empty strings.
›
Count with a grouping operation (groupingBy { it }.eachCount() in Kotlin, Collectors.groupingBy(…, counting()) in Java, a dictionary in Swift, a Map in TypeScript), then sort by count descending and word ascending, and take n.
SolutionTry it yourself firstTime O(w log w)Space O(w)
func topWords(_ text: String, _ n: Int) -> [(word: String, count: Int)] {
let words = text.lowercased()
.split(whereSeparator: { !$0.isLetter })
.map(String.init)
let counts = words.reduce(into: [:]) { counts, word in counts[word, default: 0] += 1 }
return counts
.sorted { $0.value != $1.value ? $0.value > $1.value : $0.key < $1.key }
.prefix(n)
.map { (word: $0.key, count: $0.value) }
}fun topWords(text: String, n: Int): List<Pair<String, Int>> =
text.lowercase()
.split(Regex("[^\\p{L}]+"))
.filter { it.isNotEmpty() }
.groupingBy { it }
.eachCount()
.entries
.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key })
.take(n)
.map { it.key to it.value }static List<Map.Entry<String, Long>> topWords(String text, int n) {
return Arrays.stream(text.toLowerCase().split("[^\\p{L}]+"))
.filter(w -> !w.isEmpty())
.collect(Collectors.groupingBy(w -> w, Collectors.counting()))
.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed()
.thenComparing(Map.Entry.comparingByKey()))
.limit(n)
.toList();
}function topWords(text: string, n: number): [string, number][] {
const counts = new Map<string, number>();
for (const word of text.toLowerCase().split(/[^\p{L}]+/u).filter(Boolean)) {
counts.set(word, (counts.get(word) ?? 0) + 1);
}
return [...counts]
.toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, n);
}w is the number of words: counting is O(w), sorting the distinct words is O(u log u) ≤ O(w log w). The tie-break (thenBy, thenComparing) makes the result deterministic, which is what lets you write a test for it. If n is small and the text is huge, a heap of size n brings sorting down to O(u log n).
Eager, lazy and single-use
- Eager by default in Swift and Kotlin:
orders.filter { … }.map { … }.first()builds a full filtered list and a full mapped list, then takes one element. On 10 items that’s nothing; on a million, it’s two big allocations for one result. - Lazy on request: Swift
orders.lazy.filter { … }.map { … }.firstand Kotlinorders.asSequence().filter { … }.map { … }.first()process element by element and stop early. Use them for large inputs and short-circuiting pipelines; for small collections, eager is usually faster (less overhead per element). - TypeScript arrays are eager too;
for … ofwithbreak, or iterator helpers (array.values().filter(…).map(…).take(1), ES2025, Node 22+), process one element at a time. - Java streams are always lazy and single-use: a stream can’t be consumed twice (
IllegalStateException: stream has already been operated upon or closed). Return collections from methods, not streams, unless the caller is meant to consume them once (and close them, for I/O-backed streams likeFiles.lines).
Slowest endpoints in a 10 GB log
An access log has one line per request: 2026-11-01T10:00:00Z GET /api/orders 200 183ms. The file is 10 GB (about 100 million lines) and doesn’t fit in memory. Report the 10 endpoints with the highest average latency, among endpoints with at least 1,000 requests, and say how you’d change it for p95.
›
Read the file as a lazy sequence of lines and aggregate as you go. What do you need to keep per endpoint to compute an average?
›
For averages, a count and a sum per endpoint is enough, so memory depends on the number of endpoints, not lines. Percentiles need more: a histogram per endpoint.
SolutionTry it yourself firstTime O(lines + e log e)Space O(endpoints)
func slowestEndpoints(at url: URL, top: Int = 10, minRequests: Int = 1_000) async throws -> [(String, Double)] {
var stats: [String: (count: Int, totalMs: Int)] = [:]
for try await line in url.lines { // AsyncSequence: one line at a time
let parts = line.split(separator: " ")
guard parts.count >= 5, let ms = Int(parts[4].dropLast(2)) else { continue }
let endpoint = "\(parts[1]) \(normalize(String(parts[2])))"
stats[endpoint, default: (0, 0)].count += 1
stats[endpoint, default: (0, 0)].totalMs += ms
}
return stats
.filter { $0.value.count >= minRequests }
.map { ($0.key, Double($0.value.totalMs) / Double($0.value.count)) }
.sorted { $0.1 > $1.1 }
.prefix(top)
.map { $0 }
}data class Stats(var count: Long = 0, var totalMs: Long = 0) {
val averageMs get() = totalMs.toDouble() / count
}
fun slowestEndpoints(path: Path, top: Int = 10, minRequests: Long = 1_000): List<Pair<String, Double>> {
val stats = HashMap<String, Stats>()
path.useLines { lines -> // lazy: one line in memory at a time
lines.forEach { line ->
val parts = line.split(' ')
if (parts.size < 5) return@forEach // skip malformed lines
val endpoint = parts[1] + " " + normalize(parts[2]) // "GET /api/orders/{id}"
val ms = parts[4].removeSuffix("ms").toLongOrNull() ?: return@forEach
stats.getOrPut(endpoint) { Stats() }.apply { count++; totalMs += ms }
}
}
return stats.entries
.filter { it.value.count >= minRequests }
.sortedByDescending { it.value.averageMs }
.take(top)
.map { it.key to it.value.averageMs }
}
/** /api/orders/123 -> /api/orders/{id}, so ids don't create millions of "endpoints". */
fun normalize(path: String) = path.replace(Regex("/\\d+"), "/{id}")static List<Map.Entry<String, Double>> slowestEndpoints(Path path) throws IOException {
record Stats(long count, long totalMs) {
Stats add(long ms) { return new Stats(count + 1, totalMs + ms); }
}
Map<String, Stats> stats = new HashMap<>();
try (Stream<String> lines = Files.lines(path)) { // lazy, and must be closed
lines.map(l -> l.split(" "))
.filter(p -> p.length >= 5)
.forEach(p -> {
String endpoint = p[1] + " " + p[2].replaceAll("/\\d+", "/{id}");
long ms = Long.parseLong(p[4].replace("ms", ""));
stats.merge(endpoint, new Stats(1, ms), (a, b) -> a.add(b.totalMs()));
});
}
return stats.entrySet().stream()
.filter(e -> e.getValue().count() >= 1_000)
.map(e -> Map.entry(e.getKey(), (double) e.getValue().totalMs() / e.getValue().count()))
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(10)
.toList();
}async function slowestEndpoints(path: string, top = 10, minRequests = 1_000): Promise<[string, number][]> {
const stats = new Map<string, { count: number; totalMs: number }>();
const lines = createInterface({ input: createReadStream(path) }); // node:readline: one line at a time
for await (const line of lines) {
const parts = line.split(" ");
if (parts.length < 5) continue; // skip malformed lines
const ms = Number.parseInt(parts[4], 10);
if (Number.isNaN(ms)) continue;
const endpoint = `${parts[1]} ${parts[2].replace(/\/\d+/g, "/{id}")}`;
const s = stats.get(endpoint) ?? { count: 0, totalMs: 0 };
s.count++;
s.totalMs += ms;
stats.set(endpoint, s);
}
return [...stats]
.filter(([, s]) => s.count >= minRequests)
.map(([endpoint, s]) => [endpoint, s.totalMs / s.count] as [string, number])
.toSorted((a, b) => b[1] - a[1])
.slice(0, top);
}Memory is O(number of distinct endpoints), a few hundred entries, regardless of file size: the file is read lazily (useLines, Files.lines, URL.lines, readline) and each line is folded into running totals. Two details make it production-grade: normalizing paths (otherwise /orders/1 … /orders/9999999 explode the map) and skipping malformed lines instead of crashing at line 73 million.
For p95, a sum isn’t enough, and keeping every latency is O(lines). Keep a fixed-size histogram per endpoint (buckets of, say, 1 ms up to 10 s, or an HdrHistogram / t-digest), which answers any percentile with bounded memory and small error. And at this size, consider parallelizing by splitting the file into chunks, aggregating each into its own map, and merging the maps: the operation is associative, which is exactly what makes reduce parallelizable.
Deep dives
Kotlin
Deep diveSequences vs listsInterview favorite
List operations in Kotlin are eager: list.map { }.filter { }.first() builds a complete new list for map, another for filter, and only then takes the first element. list.asSequence().map { }.filter { }.first() is lazy: each element goes through the whole chain before the next one starts, and it stops as soon as first() has its answer.
Sequences win for long chains over large collections and for short-circuiting operations (first, take, any). For small lists, the per-element overhead of sequences makes plain lists faster. Java’s streams are lazy like sequences; Swift’s equivalent is .lazy.
Java
Deep diveList.of vs Arrays.asListInterview favorite
List.of(…) is truly immutable: add, set and remove throw UnsupportedOperationException, it rejects null elements, and even contains(null) throws a NullPointerException.
Arrays.asList(array) is a fixed-size view of the array: set works and writes through to the array, but add and remove throw. It accepts null. Mixing them up is a classic: code that works with one fails at runtime with the other, and stream().toList() (Java 16) returns yet another unmodifiable list that, unlike List.of, allows null.
Swift
Deep diveLazy collectionsInterview favorite
array.map { }.filter { }.first creates an intermediate array for map and another for filter. array.lazy.map { }.filter { }.first processes elements one at a time and stops at the first match, with no intermediate arrays.
The catch: a lazy collection re-runs its closures every time you access it. Store let names = users.lazy.map(expensiveFormat) and iterate it twice, and expensiveFormat runs twice per element. Use .lazy for single passes and short-circuiting, and turn the result into an Array when you need it more than once.
TypeScript
Deep diveMethods that mutate and methods that copyInterview favorite
Some array methods change the array in place and return it: sort, reverse, splice, fill, push, pop. Others return a new array: map, filter, slice, concat. Mixing them up is a source of bugs in React state, where state.items.sort() mutates the state you were supposed to replace.
ES2023 added copying versions of the mutating ones: toSorted, toReversed, toSpliced and with(index, value). And remember that sort() without a comparator compares elements as strings, so [10, 9, 1].sort() gives [1, 10, 9].
| Swift | Kotlin | Java | TypeScript | |
|---|---|---|---|---|
| transform / keep | map, filter, compactMap | map, filter, mapNotNull | map, filter (stream) | map, filter, flatMap |
| group / index | Dictionary(grouping:by:) | groupBy, associateBy | groupingBy, toMap | Map.groupBy (ES2024), new Map(entries) |
| split / flatten | two filters, flatMap | partition, flatMap, chunked, windowed | partitioningBy, flatMap | two filters, flatMap |
| laziness | .lazy | asSequence() | streams are lazy and single-use | arrays are eager; iterator helpers (ES2025) are lazy |
Swift, Kotlin
Chains are eager: filter { … }.map { … }.first() builds two full collections to return one element. Opt into laziness (.lazy, asSequence()) for large inputs and short-circuiting; for small collections eager is usually faster.
Java
Streams are lazy and single-use: a second terminal operation throws IllegalStateException. Return collections from methods, not streams, unless the caller consumes them once (and closes I/O-backed ones like Files.lines).
TypeScript
Array methods are eager: each step builds a full new array. For large inputs, use a for … of loop or iterator helpers (values().filter(…).take(…), ES2025, Node 22+), which stop early. Watch the mutating ones: sort and reverse change the array in place (use toSorted / toReversed), and sort() without a comparator sorts numbers as text.
Check this part
Which operation turns each item into something else?
Does
orders.filter(…)changeorders?orders.filter { … }.map { … }.firston a million orders: how much work happens?orders.filter { … }.map { … }.first()on a million orders: how much work happens?You call.count()on a stream, then.toList()on the same stream. What happens?orders.filter(…).map(…)[0]on a million orders: how much work happens?