Skip to content
Commitwise
Menu
Programming

Collections and functional operations

Choose between lists, sets and maps, and transform data with map, filter, reduce, groupBy and friends in Swift, Kotlin, Java and TypeScript, with the laziness and performance details that matter in production.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

“Total revenue of paid orders, per customer, top three.” With loops, that’s a temporary list, a map, a nested if, another loop to sort, and a counter to stop at three: twenty lines where a bug can hide in any of them. With functional operations, it’s five lines that read like the sentence.

Every app transforms collections constantly: filtering a feed, totaling a cart, grouping messages by day. This tutorial covers the building blocks in Swift, Kotlin, Java and TypeScript, and the cases where they’re slower or more surprising than they look.

New to some of these? Open what you need, or skip ahead.

Basics:What are lambdas and closures?

A lambda (Kotlin, Java) or closure (Swift) is a small function without a name that you can store in a variable or pass to another function:

Swift:   numbers.filter { $0 > 10 }           // $0 is the first parameter
Kotlin:  numbers.filter { it > 10 }           // it is the single parameter
Java:    numbers.stream().filter(n -> n > 10)
TS:      numbers.filter((n) => n > 10)

The code inside the braces (or after ->) runs later, when the function you passed it to decides to call it: once per number here, or when a button is tapped in UI code. A closure can also use variables from the code around it.

Basics:What are lists, sets and maps?

Collections hold many values:

  • A list (Swift Array, Kotlin List, Java List) keeps items in order, and allows duplicates: ["Ana", "Ben", "Ana"].
  • A set holds each value at most once and answers “is this in it?” very quickly.
  • A map (Swift Dictionary, Kotlin, Java and TypeScript Map) stores values under keys: "Ana" → 30, looked up by the key.

Lists use indexes starting at 0: in ["a", "b", "c"], "a" is at index 0 and "c" at index 2.

Basics:Mutable or immutable?

A mutable object can be changed after it’s created (list.add(x), user.name = "Ana"). An immutable one can’t: to “change” it, you create a new one with the new value.

Immutable data is easier to reason about: nobody can change it behind your back, and it’s safe to share between parts of the program (and between threads). Kotlin’s val properties and List, Java’s record and List.of, Swift’s let and structs, and TypeScript’s readonly are the usual tools.

Basics:What is hashing?

A hash is a number computed from a value, like a fingerprint: the same value always gives the same hash, and different values usually give different ones. "Ana".hashCode() (Java, Kotlin) or Swift’s Hashable produce it; TypeScript’s Map and Set compute it for you.

Hash maps and hash sets use the hash to decide where to store each key, so they can jump straight to it later instead of searching: that’s why looking up a key takes about one step. Two different keys can occasionally have the same hash (a collision); the map handles it, just a little slower.

Part Not started

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:

  • filter keeps only the items that pass a test (remove the damaged ones).
  • map turns each item into something else (paint each one).
  • reduce (or sum, 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())                                              // neighbors
val 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()                                     // neighbors
Map<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 by
const 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.

  1. Filter the order lines to the last 30 days.
  2. Group them by category, and sum each group’s revenue (price × quantity).
  3. Compute the total once, from the grouped sums.
  4. Sort categories by revenue, descending, and take five.
  5. 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.

Exerciseeasy

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 { … }.first and Kotlin orders.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 … of with break, 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 like Files.lines).
Exercisemedium

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

SwiftKotlinJavaTypeScript
transform / keepmap, filter, compactMapmap, filter, mapNotNullmap, filter (stream)map, filter, flatMap
group / indexDictionary(grouping:by:)groupBy, associateBygroupingBy, toMapMap.groupBy (ES2024), new Map(entries)
split / flattentwo filters, flatMappartition, flatMap, chunked, windowedpartitioningBy, flatMaptwo filters, flatMap
laziness.lazyasSequence()streams are lazy and single-usearrays 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

  1. Which operation turns each item into something else?

  2. Does orders.filter(…) change orders?

  3. orders.filter { … }.map { … }.first on 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?

filter keeps, map transforms, reduce combines; chained, they read like the sentence they implement and leave the original untouched.

Part Not started

Combining everything into one value

reduce (and its relatives fold and sum) walks the collection and keeps a running result: start at 0, add the first price, add the second, and so on. The safe way always says where to start, because the start is also the answer when the collection is empty:

Folds take an initial value (the identity of the operation) and a combining function. The languages disagree on reductions without an identity: Swift has none, Kotlin’s reduce throws on an empty collection, Java’s returns an Optional, and JavaScript’s throws a TypeError. A parallel reduction also needs the operation to be associative, so chunks can be combined in any grouping.

let total = prices.reduce(0, +)                        // 0 for an empty array
val total = prices.fold(0) { sum, price -> sum + price }   // or prices.sum()
int total = prices.stream().mapToInt(Integer::intValue).sum();   // 0 for an empty list
const total = prices.reduce((sum, price) => sum + price, 0);   // 0 for an empty array

Without a starting value, an empty collection has no first item to start from. Swift always asks for one. Kotlin’s reduce { } and TypeScript’s reduce without the 0 throw on an empty list, and Java’s reduce(Integer::sum) returns an empty Optional.

Predict the output

What gets printed for an empty list?

let prices: [Int] = []
print(prices.reduce(0, +))
val prices = emptyList<Int>()
println(prices.reduce { total, price -> total + price })
List<Integer> prices = List.of();
System.out.println(prices.stream().reduce(Integer::sum));
const prices: number[] = [];
console.log(prices.reduce((total, price) => total + price));
SwiftKotlinJavaTypeScript
combinereduce(0, +), reduce(into:)fold, sum, reduce (throws on empty)reduce(identity, op), mapToInt().sum()reduce(op, initial) (throws on empty without it)

A parallel reduction must be associative ((a + b) + c == a + (b + c)), because chunks are combined in whatever grouping the scheduler picks.

Check this part

  1. What makes reduce / fold safe for an empty collection?

  2. [Int]().reduce(0, +) gives…emptyList<Int>().reduce { a, b -> a + b } gives…List.<Integer>of().stream().reduce(Integer::sum) gives…([] as number[]).reduce((a, b) => a + b) gives…

  3. Why must the operation of a parallel reduction be associative?

reduce, fold and sum combine a collection into one value; give them a starting value, which is also the answer for an empty collection.

Part Not started

Lists, sets and maps

You needUseSwiftKotlinJavaTypeScript
Items in order, duplicates allowedlistArrayList / MutableListList (ArrayList)Array
Unique items, fast “contains?”setSetSet / HashSetHashSetSet
Look up a value by a keymapDictionaryMap / HashMapHashMapMap (not a plain object)
Keys in a stable orderordered map— (sort keys when needed)LinkedHashMap (mutableMapOf default)LinkedHashMap, TreeMap (sorted)Map (insertion order)

Choosing the right one matters more than any operation: contains on a list checks every item, on a set it’s one step. (The Big-OBig-O notationA way to describe how an algorithm's time or memory grows as the input gets bigger.Open in glossary → tutorial goes deeper.)

Mutability and copies

  • Swift: arrays and dictionaries are values with copy-on-write, so passing one around never exposes it to mutation.
  • Kotlin: List is a read-only view, not an immutable list. Someone holding the underlying MutableList can still change it; toList() makes a copy.
  • TypeScript: arrays are mutable references; readonly T[] is a compile-time promise, and sort, reverse and splice mutate in place, while toSorted, toReversed and toSpliced (ES2023) return copies.
  • Java: List.of, stream().toList() and Collectors.toUnmodifiableList() are unmodifiable copies (List.of also rejects nulls); Collections.unmodifiableList(list) is a view that changes when the original does; Collectors.toList() gives no unmodifiability guarantee at all.

Performance details

  • Boxing: List<Int> in Kotlin and Stream<Integer> in Java box every number; use IntArray/sumOf and IntStream/mapToInt in hot paths.
  • Parallel streams split work over the common fork-join pool: only worth it for CPU-heavy work on large data, never with side effects, and never for blocking I/O (it starves every other parallel stream in the JVM).
  • Default sort in TypeScript compares as strings: [10, 9, 1].sort() is [1, 10, 9]. Always pass a comparator for numbers ((a, b) => a - b).
  • Sorting is stable in Kotlin, Java and JavaScript (since ES2019) (sortedBy, List.sort for objects) and Swift’s sort is documented as stable too, so sorting by one key and then another works, but an explicit comparator chain (thenBy, thenComparing) says what you mean.

Duplicate keys

Predict the output

Two users share the email ana@example.com (a data bug). What happens when you index users by email?

let byEmail = Dictionary(uniqueKeysWithValues: users.map { ($0.email, $0) })
print(byEmail.count)
val byEmail = users.associateBy { it.email }
println(byEmail.size)
Map<String, User> byEmail = users.stream().collect(Collectors.toMap(User::email, u -> u));
System.out.println(byEmail.size());
const byEmail = new Map(users.map((u) => [u.email, u]));
console.log(byEmail.size);

Swift

Arrays and dictionaries are values with copy-on-write. Dictionary(uniqueKeysWithValues:) traps on a duplicate key; use Dictionary(_:uniquingKeysWith:) when duplicates are possible.

Kotlin

List is a read-only view; toList() copies. associateBy silently keeps the last element per key. mutableMapOf() keeps insertion order. Boxing: prefer IntArray / sumOf in hot paths.

Java

List.of / stream().toList() are unmodifiable copies, Collections.unmodifiableList is a view, Collectors.toList() guarantees nothing. Collectors.toMap throws on a duplicate key unless you pass a merge function. Boxing: prefer IntStream / mapToInt in hot paths.

TypeScript

Use Map and Set for dynamic keys (objects compare by identity). new Map(entries) and Object.fromEntries silently keep the last value per key. Numbers aren’t boxed, but sort() without a comparator compares as strings.

Check this part

  1. You need to check "have I already seen this id?" thousands of times. Which collection?

  2. Which map keeps keys in the order you inserted them?

  3. Two users share an email, and you build Dictionary(uniqueKeysWithValues: users.map { ($0.email, $0) }). What happens?Two users share an email, and you call users.associateBy { it.email }. What happens?Two users share an email, and you collect with Collectors.toMap(User::email, u -> u). What happens?Two users share an email, and you build new Map(users.map((u) => [u.email, u])). What happens?

Pick the collection for the job: lists for order, sets for contains, maps for lookups by key.

Part Not started

Changing a list while walking it

Spot the bugClear completed todos

Tap the line with the bug.

func clearCompleted(_ todos: inout [Todo]) {
    for i in todos.indices {
        if todos[i].isDone {
            todos.remove(at: i)
        }
    }
}
fun clearCompleted(todos: MutableList<Todo>) {
    for (todo in todos) {
        if (todo.isDone) {
            todos.remove(todo)
        }
    }
}
static void clearCompleted(List<Todo> todos) {
    for (Todo todo : todos) {
        if (todo.isDone()) {
            todos.remove(todo);
        }
    }
}
function clearCompleted(todos: Todo[]) {
  for (let i = 0; i < todos.length; i++) {
    if (todos[i].isDone) {
      todos.splice(i, 1);
    }
  }
}

Side effects in parallel

Spot the bugResults that go missing under load

Tap the line with the bug.

var thumbnails: [Thumbnail] = []
DispatchQueue.concurrentPerform(iterations: images.count) { i in
    let thumb = makeThumbnail(images[i])
    thumbnails.append(thumb)
}
val thumbnails = mutableListOf<Thumbnail>()
images.parallelStream().forEach { image ->
    val thumb = makeThumbnail(image)
    thumbnails.add(thumb)
}
List<Thumbnail> thumbnails = new ArrayList<>();
images.parallelStream().forEach(image -> {
    Thumbnail thumb = makeThumbnail(image);
    thumbnails.add(thumb);
});
const thumbnails: Thumbnail[] = [];
await Promise.all(images.map(async (image) => {
  const thumb = await makeThumbnail(image);
  thumbnails.push(thumb);
}));

  • Remove with the operation made for it (removeAll, removeIf) or build a new collection with filter; never mutate the collection you’re iterating.
  • Functions passed to map and filter should be pure, especially in parallel: let the parallel operation produce results instead of writing to shared state.

Swift

In Swift 6 mode, the compiler rejects mutating a captured var from concurrently executing code. Use a task group and collect results by index.

Kotlin, Java

Parallel streams run on the common fork-join pool: only for CPU-heavy work on large data, never with side effects or blocking I/O. parallelStream().map(…).toList() keeps the original order.

TypeScript

JavaScript code runs on one thread, so a shared array can’t be corrupted, but pushing results from Promise.all callbacks stores them in completion order. await Promise.all(items.map(work)) keeps the input order.

Check this part

  1. You loop over todos.indices and call todos.remove(at: i) for done items. What happens?You loop over todos with for and call todos.remove(todo) for done items. What happens?You loop over todos with for-each and call todos.remove(todo) for done items. What happens?You loop with for (let i = 0; i < todos.length; i++) and call todos.splice(i, 1) for done items. What happens?

  2. What's the right way to remove all done todos?

  3. Many threads append thumbnails to one shared array inside concurrentPerform. What happens?A parallelStream().forEach adds thumbnails to one shared mutableListOf(). What happens?A parallelStream().forEach adds thumbnails to one shared ArrayList. What happens?Promise.all(images.map(async (img) => thumbnails.push(await make(img)))). What happens?

Never remove items from a list while looping over it; use removeAll, removeIf or filter, and keep functions in pipelines free of side effects.

Key takeaways

  • filter keeps, map transforms, reduce/fold/sum combine; chains read top to bottom like the sentence they implement.
  • Pick the collection for the job: lists for order, sets for “contains”, maps for lookups by key.
  • Never remove items while looping over the same list; use removeAll/removeIf or filter.
  • Mind the edge cases: empty collections, ties when sorting, division by zero in totals.
  • Swift, Kotlin and TypeScript chains are eager, Java streams lazy and single-use; opt into .lazy/asSequence()/iterator helpers for large inputs.
  • Know mutability guarantees: Kotlin read-only views, Java unmodifiable copies vs views, Swift values.
  • Duplicate keys, empty reductions and boxing behave differently per language; decide explicitly.
  • Keep functions in pipelines pure, especially in parallel; aggregate huge data in one lazy pass with bounded state.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.