Collection operations through practical scenarios
The important skill is not remembering names. It is predicting output, duplicate behavior, failure on empty input, ordering, and allocation.
Transformation: map, flatMap, and flatten
val names = users.map { user -> user.name }
map produces exactly one output for each input.
val orderItems = orders.flatMap { order -> order.items }
flatMap maps each order to a list, then flattens one level. If nested lists already exist, flatten() only flattens:
val nested = listOf(listOf(1, 2), listOf(3))
println(nested.flatten()) // [1, 2, 3]
Selection: filter, partition, and mapNotNull
val (valid, invalid) = inputs.partition(::isValid)
partition evaluates each element and returns two lists. Use filter when only one side matters.
val ids = rawIds.mapNotNull(UserId::parse)
mapNotNull transforms and removes null results. This is useful only if discarding invalid entries is an intentional policy; otherwise return validation errors.
Accumulation: reduce versus fold
val total = prices.fold(Money.zero("INR")) { sum, price -> sum + price }
fold starts with an explicit initial accumulator and works for empty collections. reduce uses the first element as the initial accumulator and throws on empty input.
emptyList<Int>().fold(0, Int::plus) // 0
// emptyList<Int>().reduce(Int::plus) // throws
Use fold when a meaningful identity exists or the result type differs from element type.
Indexing: associateBy, associateWith, and groupBy
data class User(val id: Int, val team: String, val name: String)
val byId: Map<Int, User> = users.associateBy(User::id)
val byTeam: Map<String, List<User>> = users.groupBy(User::team)
associateBystores one value per key; later duplicates overwrite earlier ones.groupBykeeps a list of all values for each key and allocates those lists.associateWithuses each original element as a key and computes its value.
Duplicate-key trap
val users = listOf(
User(1, "Android", "Old"),
User(1, "Android", "New"),
)
println(users.associateBy(User::id)[1]?.name) // New
If duplicate IDs indicate corrupt data, silently keeping the last is wrong. Validate duplicates before indexing.
Predicate operations short-circuit
| Operator | Meaning on elements | Empty collection result |
|---|---|---|
any(predicate) | At least one matches | false |
all(predicate) | Every element matches | true |
none(predicate) | No element matches | true |
all on an empty collection is true by vacuous truth. That can surprise validation code:
val allApproved = requests.all(Request::isApproved)
If “at least one request must exist” is also required, check requests.isNotEmpty() && requests.all(...).
Pairing: zip and unzip
val labels = listOf("Name", "Age")
val values = listOf("Rakesh", "35", "Ignored")
println(labels.zip(values)) // [(Name, Rakesh), (Age, 35)]
zip stops at the shorter collection. It does not report missing values. Use key-based association when position is not a guaranteed contract.
val pairs = listOf("U-1" to "Rakesh", "U-2" to "Anu")
val (ids, names) = pairs.unzip()
Slicing from the start and end
There are two slicing families:
| Family | Start side | End side | Stops because of |
|---|---|---|---|
| Fixed count | take(n), drop(n) | takeLast(n), dropLast(n) | Number of elements |
| Predicate | takeWhile, dropWhile | takeLastWhile, dropLastWhile | First predicate failure from that side |
Fixed-count slicing
val values = listOf(10, 20, 30, 40, 50)
println(values.take(2)) // [10, 20]
println(values.drop(2)) // [30, 40, 50]
println(values.takeLast(2)) // [40, 50]
println(values.dropLast(2)) // [10, 20, 30]
If n exceeds the collection size, take/takeLast return all elements and drop/dropLast return an empty list. A negative n throws IllegalArgumentException.
Predicate slicing: the complete four-operation comparison
These operate on a consecutive boundary, not every matching element:
val values = listOf(2, 4, 1, 6, 8)
println(values.takeWhile { it % 2 == 0 }) // [2, 4]
println(values.dropWhile { it % 2 == 0 }) // [1, 6, 8]
println(values.takeLastWhile { it % 2 == 0 }) // [6, 8]
println(values.dropLastWhile { it % 2 == 0 }) // [2, 4, 1]
Read the output from the boundary:
takeWhilestarts at the beginning and keeps matching values until1fails.dropWhilestarts at the beginning and removes matching values until1fails; everything after that remains, even if it matches.takeLastWhilestarts testing at the end and keeps the matching suffix6, 8. The returned elements remain in their original order.dropLastWhileremoves that matching suffix and keeps the prefix2, 4, 1.
Use filter if you mean “all matching values anywhere.” For this input, filter returns [2, 4, 6, 8].
The word Last changes which boundary is inspected; it does not reverse the returned list. takeLastWhile scans the suffix condition from the end but returns the retained suffix in original encounter order.
Uniqueness: distinct and distinctBy
val newestFirst = messages.sortedByDescending(Message::createdAt)
val latestPerSender = newestFirst.distinctBy(Message::senderId)
distinctBy keeps the first item encountered for each selected key. Therefore sorting/order before it is part of the result contract.
Batching and sliding analysis
val pages = (1..10).toList().chunked(3)
// [[1,2,3], [4,5,6], [7,8,9], [10]]
val movingPairs = listOf(10, 20, 30, 40).windowed(size = 2)
// [[10,20], [20,30], [30,40]]
chunked(n)creates non-overlapping batches, useful for bounded API requests.windowed(n)creates sliding windows, useful for moving comparisons; it can allocate heavily on large inputs.
Collections versus sequences
val result = hugeList
.asSequence()
.filter(::isEligible)
.map(::toUi)
.take(10)
.toList()
Collections usually execute each stage eagerly and create intermediate results. A sequence processes elements lazily through the pipeline and can stop early. Sequences are not always faster: small inputs/simple pipelines may pay iterator/lambda overhead without saving meaningful allocation. Measure hot paths.
runCatching is not a collection operator
It produces Result<T> from a block:
val parsed: Result<Int> = runCatching { raw.toInt() }
Be careful in suspend code: broad catching mechanisms can capture CancellationException depending on usage. Do not turn coroutine cancellation into an ordinary failure.
Android scenarios
API batch limit
pendingIds.chunked(100).forEach { batch ->
api.uploadBatch(batch)
}
Decide whether batches run sequentially or with bounded concurrency, what happens after partial failure, and whether the API is idempotent. chunked solves grouping, not distributed reliability.
Rendering grouped settings
val sections = settings.groupBy(Setting::category)
If order matters, ensure input/map implementation and category ordering are explicit. Do not rely on incidental implementation order as a business contract.
Deduplicating network data
associateBy(id) may hide server duplicates. Record/alert when duplicates violate the contract before selecting a winner.
Interview questions
Why does reduce fail on empty input while fold does not?
Reduce needs the first element as its initial accumulator. Fold receives an explicit initial value, so it can return that value for empty input.
What happens when associateBy sees duplicate keys?
The later value replaces the earlier value for that key. Whether that is acceptable is a domain decision.
How is takeWhile different from filter?
takeWhile stops at the first non-matching element; filter examines the whole input and retains every match.
Does takeLastWhile reverse the result?
No. It tests the consecutive suffix from the end, but returns the retained elements in their original order.
Exercises
- Predict all outputs before running the Playground example.
- Build a map that rejects duplicate IDs instead of silently overwriting.
- Calculate a moving three-sample average using
windowed. - Compare eager and sequence pipelines with logging and
take(2). - Design partial-failure handling for
chunked(100)uploads. - For
[2, 4, 1, 6, 8], predict all four...Whileslicing results before running the example.