Copilot with Kotlin: Create a new divisible by 3 list
Video: Copilot with Kotlin: Create a new divisible by 3 list by Taught by Celeste AI - AI Coding Coach
Kotlin: Filter a List for Numbers Divisible by 3
numbers.filter { it % 3 == 0 }. Thefilterextension is the canonical way to keep elements matching a predicate. Returns a new immutable list.
The cousin of episode 14's count. Where count returns "how many," filter returns "which ones."
The Copilot prompt
fun main() {
val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12)
// Filter to keep only numbers divisible by 3
Copilot generates:
val divisibleByThree = numbers.filter { it % 3 == 0 }
println(divisibleByThree) // [3, 6, 9, 12]
filter walkthrough
filter(predicate) walks the list, runs the predicate on each element, and builds a new list of elements where the predicate returned true.
Does not mutate the original list. Returns a new List<T>.
val original = listOf(1, 2, 3, 4)
val even = original.filter { it % 2 == 0 }
// original = [1, 2, 3, 4] (unchanged)
// even = [2, 4]
filterNot, filterIndexed, filterIsInstance
Variants of filter:
// "Keep ones that DON'T match" — opposite of filter
numbers.filterNot { it % 2 == 0 } // odds: [1, 3, 5, 7, 9]
// Predicate gets the index too
numbers.filterIndexed { i, n -> i % 2 == 0 } // every other element
// Type-narrow filter — for List<Any>, keep only Strings (or whatever type)
val mixed: List<Any> = listOf(1, "a", 2.0, "b", true)
val strings = mixed.filterIsInstance<String>()
// strings: List<String> = ["a", "b"]
filterIsInstance<T>() is especially useful when working with mixed-type lists or polymorphic collections.
Chaining
val result = numbers
.filter { it > 5 } // [6, 7, 8, 9, 10, 12]
.filter { it % 3 == 0 } // [6, 9, 12]
.map { it * 2 } // [12, 18, 24]
.sortedDescending() // [24, 18, 12]
Each step produces a new list. Functional, readable, easy to reason about — but every intermediate step allocates a list. For huge inputs, switch to a Sequence:
val result = numbers.asSequence()
.filter { it > 5 }
.filter { it % 3 == 0 }
.map { it * 2 }
.sortedDescending()
.toList()
asSequence() makes the chain lazy. No intermediate lists; each element flows through the whole pipeline before the next is processed.
filter vs partition
If you want both the matches and the non-matches:
val (divisibles, others) = numbers.partition { it % 3 == 0 }
// divisibles = [3, 6, 9, 12]
// others = [1, 2, 4, 5, 7, 8, 10]
partition(predicate) returns a Pair<List<T>, List<T>> — first list is matches, second is non-matches. One pass through the list.
If you only need one side, filter is simpler. If you need both, partition saves a pass.
filter vs filterNot vs none + count
For "are there any matching?" use any { ... }. For "are all matching?" all { ... }. For "none matching?" none { ... }. These short-circuit; filter doesn't.
numbers.any { it % 3 == 0 } // true (faster than filter().isNotEmpty())
numbers.all { it > 0 } // true
numbers.none { it < 0 } // true
Use the right tool: filter for "give me the matches," predicates like any/all/none for boolean checks.
Extensions on Map
val byCategory = mapOf(
"fruit" to 5, "vegetable" to 3, "meat" to 0, "dairy" to 7
)
val nonEmpty = byCategory.filter { it.value > 0 }
// {fruit=5, vegetable=3, dairy=7}
val keys = byCategory.filterKeys { it.startsWith("f") }
val values = byCategory.filterValues { it >= 5 }
filter on a Map gives you Map.Entry<K, V>. There are also filterKeys and filterValues for when you only need one side.
Performance
filter is O(n) — one pass. The predicate runs once per element. The new list allocates space for the matches.
For huge lists where you only iterate the result (no random access), Sequence avoids the intermediate allocation:
val sumOfDivisibles = (1..1_000_000).asSequence()
.filter { it % 3 == 0 }
.sum()
Without .asSequence(), (1..1_000_000).filter { ... } builds a 333,333-element list before summing. With it, no intermediate list — each element flows through.
Common mistakes
Mutating the result list. filter returns List<T>, not MutableList<T>. To mutate, use filterTo(mutableListOf()).
Forgetting the result is a new list. numbers.filter { ... } doesn't change numbers. Assign it.
Using filter() for "any match." any { predicate } is faster (short-circuits).
Modulo on Double. (1.0).rem(3.0) == 0.0 works but float precision is fiddly. For divisibility, prefer Int/Long.
Confusing filter with filterIndexed. Sometimes you need the index too — use filterIndexed { i, v -> ... }.
What's next
Episode 25: Higher-order functions — pass an operation as a parameter. The functional building block: a function that takes a function.
Recap
numbers.filter { it % 3 == 0 } keeps matching elements. Returns a new immutable list. Variants: filterNot, filterIndexed, filterIsInstance<T>. For a Map: filterKeys, filterValues. For "match + non-match" in one pass: partition. For huge lists, asSequence() keeps the chain lazy. For boolean checks, prefer any/all/none (short-circuiting).
Next episode: higher-order functions.