Part of Github Copilot with Kotlin

Copilot with Kotlin: Create a new divisible by 3 list

Sandy LaneSandy Lane

Video: Copilot with Kotlin: Create a new divisible by 3 list by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Kotlin: Filter a List for Numbers Divisible by 3

numbers.filter { it % 3 == 0 }. The filter extension 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.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.