Recommended resources
The freely available resources this lesson will direct you to.
- Sequence Explained - Kotlin Collectionswww.youtube.com
- Functional Kotlin w/ Higher Order Functions & Kotlin Standard Library - Part 14 Kotlin Tutorial 2020www.youtube.com
- Collection transformation operations | Kotlin Documentationkotlinlang.org
- Higher-order functions with collections | Android Developersdeveloper.android.com
Hello! In the previous lesson, you used generic types and extension functions to make operations reusable. Kotlin’s collection API puts those ideas to work: functions such as filter and map are generic extensions that accept a function describing what to do with each element.
This lesson focuses on turning lists of values into useful results. You’ll write lambdas, combine collection operations, build grouped and aggregated results, and decide when a sequence is preferable to an ordinary collection pipeline. The examples continue with the Message and Channel types from earlier lessons.
Give a collection an operation to perform
Suppose you have four messages:
val messages = listOf(
Message("r1", "Ready", Channel.EMAIL),
Message("r2", "Code 1234", Channel.SMS),
Message("r1", "Reminder", Channel.EMAIL),
Message("r3", " ", Channel.SMS)
)
The following snippets assume messages is in scope. listOf gives you a read-only List<Message> view: you cannot add elements through that list. When you need to change a list’s contents, use a mutable collection such as mutableListOf. Most transformations in this lesson leave their input alone and return a result instead.
A higher-order function accepts a function as an argument or returns one. For example, filter accepts a function that decides whether each message belongs in its result:
val eligible: List<Message> =
messages.filter { message -> message.body.isNotBlank() }
The expression in braces is a lambda. Its last expression supplies the result—in this case, a Boolean. Kotlin infers that message is a Message from the list’s element type. With one lambda parameter, you can use the implicit name it instead:
val eligible = messages.filter { it.body.isNotBlank() }
Watch the opening of Functional Kotlin w/ Higher Order Functions & Kotlin Standard Library by goobar. The presenter writes a function that takes a predicate, calls that predicate for each item, and then passes a lambda at the call site. Stop before the discussion of nullable function parameters.
Functional Kotlin w/ Higher Order Functions & Kotlin Standard Library - Part 14 Kotlin Tutorial 2020
Functional Kotlin w/ Higher Order Functions & Kotlin Standard Library by goobar introduces function-valued parameters through a filtering example.
Watch function parameters. Focus on the function type that accepts a string and returns a Boolean, how the function invokes its predicate, and why a final lambda can sit outside the call’s parentheses.
Here is the same idea applied to a reusable piece of message-processing code:
fun <T> labelsFor(
items: List<T>,
include: (T) -> Boolean,
render: (T) -> String
): List<String> =
items.filter(include).map(render)
val smsLabels = labelsFor(
messages,
include = { it.channel == Channel.SMS && it.body.isNotBlank() },
render = { "${it.recipient}: ${it.body}" }
)
// [r2: Code 1234]
(T) -> Boolean describes the selection rule; (T) -> String describes the rendering rule. The function works with any input element type while preserving type checking. You will often use standard collection functions directly, but writing a higher-order function is useful when a combination of operations expresses a repeated rule.
Select, transform, and flatten
filter retains elements of the original type. In the image below, the rule keeps the even numbers; their relative order is unchanged.

map, by contrast, produces one result for every input element and can change the element type. You can compose the two to obtain SMS recipients with nonblank messages:
val smsRecipients: List<String> = messages
.filter { it.channel == Channel.SMS && it.body.isNotBlank() }
.map { it.recipient }
// [r2]
The first call returns a List<Message>; the second returns a List<String>. This is often clearer than a loop that manually appends to a result list. forEach has a different purpose: it performs an action, such as printing, for every element. Use map when you want values, not merely side effects.
Sometimes the transformation itself can produce no value. mapNotNull combines mapping with discarding null results:
val parsed = listOf("12", "unknown", "7")
.mapNotNull { it.toIntOrNull() }
// [12, 7]
Here toIntOrNull() returns an Int?; the result of mapNotNull is a List<Int>. Notice the choice: an invalid entry disappears. That is appropriate only if dropping it matches your requirement; the next lesson will examine failure-handling choices more closely.
Read the Map section of Kotlin’s Collection transformation operations for the precise map and mapNotNull behavior.
Collection transformation operations | Kotlin Documentation
Kotlin’s official documentation describes what mapping preserves and how nullable mapping results can be omitted.
In the “Map” section, begin with mapping and order. Continue through the paragraph beginning “If the transformation produces” and ending with the mapIndexedNotNull() alternative; use nullable results to locate it. Focus on the difference between one output per input and omitting null outputs.
A related case is one input producing several outputs. If messages arrive in batches, flatMap can give you one list of messages:
val batches: List<List<Message>> =
listOf(messages.take(2), messages.drop(2))
val allMessages: List<Message> =
batches.flatMap { batch -> batch }
map here would produce another List<List<Message>>; flatMap combines the inner lists into one List<Message>. The more general pattern is to return a collection from the lambda and combine all those returned collections.
Change the shape of the result
A backend may need messages organized by channel rather than one flat list. groupBy calls a key-selection lambda for each element and returns a map whose values are lists:
val byChannel: Map<Channel, List<Message>> =
eligible.groupBy { it.channel }
val smsMessages: List<Message> =
byChannel[Channel.SMS].orEmpty()
A map lookup can return null when the key is absent; orEmpty() gives you an empty list in that case. Grouping preserves all eligible messages for a channel.

Compare grouping with associateBy, which builds a map holding one value per key:
val latestByRecipient: Map<String, Message> =
messages.associateBy { it.recipient }
Two sample messages have recipient "r1". In this map, the later one replaces the earlier one. If you need all messages per recipient, use groupBy { it.recipient } instead. Choosing between the two is a data-model decision, not just a syntax preference.
Other operations answer different questions about the same list:
val recipientOrder = eligible.sortedBy { it.recipient }
val totalCharacters = eligible.fold(0) { total, message ->
total + message.body.length
}
val messageCount = eligible.count { it.channel == Channel.EMAIL }
sortedBy returns a list ordered by the selected property; it does not reorder eligible. fold starts with the supplied initial value, then uses its lambda to update an accumulator for each element. Starting at 0 means an empty list yields 0. For this particular total, eligible.sumOf { it.body.length } is shorter; fold is valuable when the accumulated result requires a custom rule or shape. count directly expresses the last question without constructing another list.
Read the focused parts of the Android Developers codelab Higher-order functions with collections. Its cookie examples use different data, but the grouping and accumulation rules are the same.
Higher-order functions with collections | Android Developers
The Android Developers codelab builds a map of groups, then uses an accumulator to calculate a total. Read these examples to connect the function signatures to the results they produce.
In “5. groupBy()”, read keys and groups, then the odd/even example from the example. Continue to the cookie grouping code, ending at the two lookups for the soft-baked and crunchy menus. In “6. fold()”, read from the explanation that a fold generates one value through the cookie total-price example and the note comparing it with reduce; the accumulator discussion covers that span. Focus on the initial value and the roles of the two lambda parameters.
For two more transformations, return to Kotlin’s documentation: associateBy makes the duplicate-key behavior explicit, and flatMap explains why the batched example ends as one list.
Collection transformation operations | Kotlin Documentation
These sections of Kotlin’s Collection transformation operations distinguish building a keyed map from combining nested collections.
In “Associate”, read the associateBy() paragraph beginning “For building maps with collection elements as values” through its examples; duplicate keys marks the key behavior. Then, in “Flatten”, start at the paragraph beginning “Another function” and read through its flatMap() example. Pay attention to combined outputs.
Decide when to use a sequence
The collection pipelines so far are eager. With a list, filter builds its result list before the following map starts; that map builds another result list. This is straightforward and often exactly what you want.
A sequence defers intermediate operations until a result is requested. It can process a qualifying element through several operations without first building a list at each stage:
val firstRecipient: String? = messages
.asSequence()
.filter { it.body.isNotBlank() }
.map { it.recipient }
.firstOrNull()
// "r1"
firstOrNull() requests the first result. Because the first message qualifies, the sequence need not process the remaining messages for this query. If you need a list rather than one result, end the pipeline with toList():
val firstTwoBodies: List<String> = messages
.asSequence()
.filter { it.body.isNotBlank() }
.map { it.body }
.take(2)
.toList()
// [Ready, Code 1234]
The operations before toList() are deferred; toList() evaluates them and creates the final list. If you know Dart’s lazy Iterable.map and where, this deferred behavior is a useful point of comparison—but Kotlin’s ordinary List.map and filter are eager, so asSequence() is the explicit switch.
Watch Cody Engel’s Sequence Explained - Kotlin Collections for the intermediate-list motivation and the caution that sequences are not automatically faster.
Sequence Explained - Kotlin Collections
Cody Engel’s Sequence Explained - Kotlin Collections shows why a chain over a list can allocate intermediate results and how asSequence() changes its evaluation.
Watch sequence evaluation for asSequence() and conversion back to a list. Then skip to performance caveats. Treat the timings as illustrations, not a guarantee for your own code; focus on collection size, pipeline length, and conversion costs.
For a short pipeline over a small list, prefer the simple collection version unless you have a reason to change it. Sequences add their own processing overhead, and not every operation can stream: sorting, for instance, must examine the elements it will order. A sequence can also recompute work if you consume it more than once. Keep transformation lambdas free of surprising side effects so that deferred evaluation does not change when those effects occur.
Key takeaways
A lambda supplies the per-element rule to a higher-order function. Use filter to retain elements, map to change them, mapNotNull to omit null transformation results, and flatMap to combine nested results. Use groupBy when every item under a key matters, associateBy when one value per key is intended, and fold when you need a custom accumulated value.
Ordinary list transformations are eager; sequences defer intermediate work and can avoid processing unneeded elements or building intermediate lists. Choose based on the result you need and the workload, not on a blanket performance rule. Next, you’ll examine what should happen when processing cannot produce the expected result, using exceptions, nullable results, and result-style types.