Collections, Lambdas, Scope Functions & Higher-Order Functions
Functional Kotlin patterns that senior Android devs use daily
Open interactive version (quiz + challenge)Real-world analogy
Collections are your data containers, lambdas are mini-functions you can pass around like notes, scope functions are context-switchers that let you 'be inside' an object temporarily, and higher-order functions are managers who accept other functions as their instructions.
What is it?
Kotlin's collection API and functional operators let you write concise, readable data transformations. Scope functions (let/run/with/apply/also) are syntactic sugar that organize code around objects. Higher-order functions that accept lambdas form the backbone of coroutines, Flow, and modern Android APIs like setContent {} in Compose.
Real-world relevance
In a school management platform with Firebase + Room, you fetch a List from Room, use map to convert to List for display, filter for only enrolled students, groupBy for class section tabs, and fold to compute total attendance percentage. apply sets up the RecyclerView adapter, let handles nullable selected student in edit screen.
Key points
- Immutable vs Mutable collections — listOf(), mapOf(), setOf() return read-only views (not truly immutable — the underlying data can change if it's a mutableList). mutableListOf(), mutableMapOf() return mutable collections. Always expose read-only interfaces from ViewModels and repositories.
- map operator — Transforms each element and returns a new list. users.map { it.name } returns List. Does not mutate the original. In Android: transform domain models to UI models.
- filter operator — Returns a new list with only elements matching the predicate. orders.filter { it.status == Status.PENDING }. Combine with map for transform-then-filter pipelines.
- groupBy, associateBy, partition — groupBy { it.category } returns Map>. associateBy { it.id } returns Map (last wins on duplicate keys). partition { it.isActive } returns Pair split by predicate.
- fold and reduce — fold(initial) { acc, item -> } accumulates with a starting value. reduce { acc, item -> } uses first element as initial — throws on empty list. Use fold for safe aggregation (totals, string building, combining states).
- Lambda syntax — { param -> body } is the full form. { it.name } uses implicit 'it' for single-param lambdas. Trailing lambda syntax: list.filter { it > 0 } instead of list.filter({ it > 0 }). Last lambda outside parens is idiomatic Kotlin.
- let scope function — obj.let { it -> transform(it) } — 'it' refers to the receiver. Returns the lambda result. Primary use: null-safe blocks (obj?.let { }) and transforming a value in a chain without a temp variable.
- apply scope function — obj.apply { this.property = value } — 'this' is the receiver, returns the receiver itself. Used for object initialization and builder patterns: AlertDialog.Builder(ctx).apply { setTitle(...); setMessage(...) }.create()
- run scope function — obj.run { doSomething() } — 'this' is the receiver, returns lambda result. Also has standalone run { } form for grouping expressions. Used when you need to compute a result from an object's context.
- with scope function — with(obj) { doSomething() } — NOT an extension function, receiver via parameter. 'this' is obj, returns lambda result. Use when the object is not nullable and you want to operate on it multiple times.
- also scope function — obj.also { it -> sideEffect(it) } — 'it' is the receiver, returns the receiver. Used for side effects like logging without interrupting a chain. list.filter { }.also { log(it.size) }.map { }
- inline functions and performance — inline fun measure(block: () -> T): T removes the lambda overhead at compile time — the lambda body is inlined at the call site. Use for performance-critical higher-order functions. Required for reified type parameters.
Code example
// Collection transformations — school management scenario
data class Student(
val id: String,
val name: String,
val section: String,
val attendanceRate: Double,
val isEnrolled: Boolean
)
fun buildSectionMap(students: List<Student>): Map<String, List<Student>> =
students
.filter { it.isEnrolled }
.groupBy { it.section }
fun averageAttendance(students: List<Student>): Double =
if (students.isEmpty()) 0.0
else students.fold(0.0) { acc, s -> acc + s.attendanceRate } / students.size
// Scope functions
fun setupAdapter(recyclerView: RecyclerView, students: List<Student>) {
recyclerView.apply {
layoutManager = LinearLayoutManager(context)
adapter = StudentAdapter(students)
setHasFixedSize(true)
}
}
// let for null-safe navigation
fun editStudent(selectedStudent: Student?) {
selectedStudent?.let { student ->
val intent = Intent(context, EditStudentActivity::class.java).apply {
putExtra("student_id", student.id)
}
startActivity(intent)
}
}
// also for side-effect logging in a chain
fun processEnrollment(students: List<Student>): List<Student> =
students
.filter { it.isEnrolled }
.also { enrolled -> Timber.d("Processing ${enrolled.size} enrolled students") }
.sortedBy { it.name }Line-by-line walkthrough
- 1. students.filter { it.isEnrolled } — creates a new list with only enrolled students; original list untouched
- 2. .groupBy { it.section } — transforms the filtered list into Map> keyed by section
- 3. students.fold(0.0) { acc, s -> acc + s.attendanceRate } — starts at 0.0, adds each student's rate; safe on empty lists
- 4. recyclerView.apply { ... } — 'this' inside is the recyclerView; returns recyclerView itself; perfect for initialization
- 5. layoutManager = LinearLayoutManager(context) — no 'recyclerView.' prefix needed inside apply; 'this' is implicit
- 6. selectedStudent?.let { student -> } — block only runs if selectedStudent is non-null; student is non-null inside
- 7. Intent(...).apply { putExtra(...) } — creates Intent and configures it in one expression; no temp variable needed
- 8. .also { enrolled -> Timber.d(...) } — side effect (logging) without interrupting the chain; returns the same list
- 9. .sortedBy { it.name } — terminal sort operation; returns a new sorted list; chain is now complete
Spot the bug
fun getTotalAmount(orders: List<Order>): Double {
return orders.reduce { acc, order -> acc + order.amount }
}
fun getActiveUserNames(users: List<User>?): List<String> {
return users.map { it.name }.filter { it.isActive }
}Need a hint?
Bug 1: reduce throws on empty list. Bug 2: two issues — nullable list not handled, and filter condition is wrong
Show answer
Bug 1: Use fold(0.0) { acc, o -> acc + o.amount } — reduce throws EmptyCollectionException on empty list. Bug 2: users.map { } on a nullable List? won't compile — fix with users?.map { it.name } ?: emptyList(). Also, isActive is a property of User not String — filter must happen BEFORE map: users?.filter { it.isActive }?.map { it.name } ?: emptyList()
Explain like I'm 5
Imagine you have a bag of colored balls (your collection). map is painting each ball a new color. filter keeps only the balls you like. fold counts up the total weight of all the balls. The scope functions (let/apply/run/with/also) are like saying 'I'm going to stand inside this ball for a moment and work on it' — each one lets you do that in slightly different ways.
Fun fact
Kotlin's scope functions are so idiomatic that Android's own Jetpack libraries are built around them — ViewBinding uses apply{}, coroutine builders use trailing lambdas, and Compose's DSL is entirely built on trailing lambda / receiver lambda patterns.
Hands-on challenge
Given a List with fields: id, agentId, amount (Double), status (PENDING/APPROVED/REJECTED), write functions using only collection operators to: 1) Get total approved amount per agent as Map. 2) Partition into (needsReview: List, processed: List). 3) Find the top 3 agents by approved claim count. Use no loops — only map/filter/groupBy/fold/sortedByDescending/take.
More resources
- Kotlin Collections Overview (kotlinlang.org)
- Scope Functions (kotlinlang.org)
- Lambdas and Higher-Order Functions (kotlinlang.org)
- Inline Functions (kotlinlang.org)