JavaRush /Courses /Kotlin SELF /mapNotNull and filterNotNull: data cleanup and type chang...

mapNotNull and filterNotNull: data cleanup and type changes

Kotlin SELF
Level 22, Lesson 3
Available

1. Why null in collections gets in our way

When you’re just starting to program, null seems useful: “well, there’s no value — so null.” The problem is that null is not just “empty”; it’s a distinct value that you constantly have to account for. In Kotlin this is reinforced by the type system: String and String? are different types, and the compiler won’t let you pretend that null will “just sort itself out somehow.”

In collections, null becomes an especially unpleasant guest for two reasons. First, it pollutes the result type and forces us to write checks in places where, logically, “everything should already be fine.” Second, null often appears as the result of safe parsing (toIntOrNull()), searching (find), or safe access (getOrNull). So it shows up not because we “love null,” but because we’re writing resilient code.

Let’s look at a mini example that’s very close to reality: we received a list of strings where someone wrote numbers and someone wrote “oops.” We want to get a list of numbers.

fun main() {
    val raw = listOf("10", "x", "7")
    val parsed = raw.map { it.toIntOrNull() }

    println(parsed) // [10, null, 7]
}

The result is formally honest: List<Int?>. But next you’ll almost certainly want to sum or compare elements — and suddenly you’ll discover that these aren’t Int, but Int?, and you can’t just “add” them. This is exactly where “cleaning up null” appears as a separate data-processing step.

2. mapNotNull: transform and drop null at the same time

If map is “transform each element,” then mapNotNull is “transform each element, but if the result is null, just drop that result.” It sounds like a small improvement, but in practice it saves a lot of frustration, because you get a non-null list and can continue without constant checks.

In the standard library documentation, the idea is stated directly: if a transformation can yield null, use mapNotNull() instead of map(). That’s exactly our case with toIntOrNull().

Let’s start with a very small example: we’ll multiply numbers, but we’ll treat 2 as suspicious and turn it into null (just to see the mechanism).

fun main() {
    val numbers = setOf(1, 2, 3)

    val result = numbers.mapNotNull { x ->
        if (x == 2) null else x * 3
    }

    println(result) // [3, 9]
}

Notice two things. First, mapNotNull returns a list with no null. Second, the result type becomes not List<Int?>, but List<Int> — and that’s the main win.

A mini mental model

You can think of mapNotNull as “map + filter by not-null,” just written shorter and clearer in intent:

element T --(transform)--> R?  --(if null, drop it)--> R

And that’s not philosophy — it’s the real practical contract of the function: “transform and discard null results.”

3. Parsing and cleaning “dirty” input

Next comes a situation that happens in console programs more often than “common sense” would like: the user entered something wrong. And our job is not to crash, not to make a drama, but to calmly extract at least the useful pieces.

toIntOrNull() is a basic tool for safe parsing: if the string is not a number, it returns null instead of throwing an exception. You’ve already seen this pattern (“read → try to parse → get T?”), and it’s considered idiomatic in Kotlin.

Example: normalization + parsing

Suppose we have “raw” strings: whitespace, empty strings, junk. We want to get a list of numbers.

fun main() {
    val raw = listOf("10", " x ", " 7 ", "")
    val numbers = raw
        .map { it.trim() }
        .mapNotNull { it.toIntOrNull() }

    println(numbers) // [10, 7]
}

What matters here is that trim() is just “cosmetics,” while the key step is mapNotNull { it.toIntOrNull() }. It turns a list of strings into a list of numbers and drops everything that failed to parse.

If you suddenly thought, “where is the message to the user that they entered junk?” — that’s a great question. But in this lecture we’re keeping the focus on types and data cleanup. Messages can be a separate step (and most often they really are separate logic; otherwise you’ll end up printing inside map, which we try not to like).

Why not map { ... }.filterNotNull()?

You can do that too. But mapNotNull is better for two reasons: it’s shorter and it describes the intent more precisely (“I understand that the transformation may produce null, and I want to drop it”). The documentation presents it as the direct tool for this case.

I’ll show both variants side by side on the same example (not as “right/wrong,” but as “a more direct way to express the thought”).

fun main() {
    val raw = listOf("1", "oops", "3")

    val a = raw.map { it.toIntOrNull() }.filterNotNull()
    val b = raw.mapNotNull { it.toIntOrNull() }

    println(a) // [1, 3]
    println(b) // [1, 3]
}

Both produce the same result. But mapNotNull reads as a single operation: “parse the numbers, ignore the junk.”

A small pipeline flowchart

Sometimes it’s useful to literally draw what’s happening:

flowchart TD
    A[Raw strings] --> B["trim()"]
    B --> C["toIntOrNull()"]
    C -->|Int| D[to the results list]
    C -->|null| E[drop it]
    D --> F[List<Int>]

This diagram is the “inner world” of mapNotNull in this specific case.

filterNotNull(): removing null from an already nullable collection

If mapNotNull saves us when null appears during a transformation, then filterNotNull() is useful when null is already sitting in the collection, and we simply need to clean it out. This is a different scenario: we don’t transform anything; we just say, “keep only non-null.”

The filtering documentation separately emphasizes that filterNotNull() returns all non-null elements and, which is especially nice, it narrows the type: from List<T?> you get List<T>.

Example: a list of names where sometimes there is null.

fun main() {
    val maybeNames: List<String?> = listOf("Ann", null, "Bob")
    val names: List<String> = maybeNames.filterNotNull()

    println(names) // [Ann, Bob]
}

Here the main effect isn’t even that null disappeared, but that the ? disappeared from the type. Now you can safely do names.map { it.length }, and the compiler won’t remind you about the fragility of existence.

An important boundary: filterNotNull() does not remove “empty”; it removes exactly null.

This is a common confusion. The empty string "" is not null. If you have listOf("", "Bob"), then filterNotNull() won’t change anything. It’s not about “bad data”; it’s specifically about the value null.

4. Data cleanup in a mini budget tracker

Now we’ll take a few small steps that look like “minor details,” but in practice they turn a console app from “broke on the very first input” into “survives in the real world.”

Let’s imagine we already have a very simple expense tracker where an expense is Triple(category, note, amount). Yes, it’s not ideal, but for now we’re consciously avoiding OOP models: it’s more important to learn how to work with collections.

Parsing an “import of amounts” in one line with mapNotNull

Suppose we want a command like this: the user enters a line with numbers separated by spaces, and we add them as expenses in the "import" category. We ignore invalid pieces.

fun parseInts(line: String): List<Int> =
    line.split(" ").mapNotNull { it.trim().toIntOrNull() }

fun main() {
    val nums = parseInts("10  x  7   -3")
    println(nums) // [10, 7, -3]
}

Notice that we’re not adding “smart” rules (for example, banning negatives). Today we’re practicing the mechanics of mapNotNull. Validation rules can be added right here by returning null when a number “doesn’t fit.”

Here’s what it looks like if we want to skip everything less than or equal to zero:

fun parsePositiveInts(line: String): List<Int> =
    line.split(" ").mapNotNull { token ->
        val n = token.trim().toIntOrNull()
        if (n != null && n > 0) n else null
    }

fun main() {
    val nums = parsePositiveInts("10  x  7   -3  0")
    println(nums) // [10, 7]
}

The key idea: since mapNotNull drops null, you can use null as a “rejection signal.” This is much more convenient than manually accumulating a list and writing a bunch of if.

Extracting expense amounts where some records may be “broken,” using filterNotNull

Now imagine another scenario: we already have a list of expenses, but somewhere along the way we got “broken” elements. For example, we have a function that tries to parse a command line into an expense and returns Triple<String, String, Int>?: if it can’t, it returns null.

This is very realistic: a parser almost always returns nullable.

fun parseExpenseOrNull(line: String): Triple<String, String, Int>? {
    val parts = line.trim().split(" ")
    val amount = parts.getOrNull(2)?.toIntOrNull()
    if (parts.size < 3 || amount == null) return null
    return Triple(parts[0], parts[1], amount)
}

fun main() {
    val rawLines = listOf(
        "food pizza 500",
        "oops",
        "taxi ride 250"
    )

    val parsed: List<Triple<String, String, Int>?> = rawLines.map(::parseExpenseOrNull)
    val expenses: List<Triple<String, String, Int>> = parsed.filterNotNull()

    println(expenses.size) // 2
}

Here filterNotNull() turns a “list of maybe-expenses” into a “list of definitely-expenses.” And this is the point where the code starts reading like a sequence of steps: parse → drop what didn’t parse → continue working with normal data.

The very contract of filterNotNull() is exactly that: keep only non-null elements and narrow the type to non-null.

The “cleanup → check” combo with mapNotNull + any

Now let’s assemble a mini chain that’s genuinely useful: “the user entered something, we extracted the numbers, and we want to know whether there are any big amounts.”

fun main() {
    val line = "10  200  x  50"
    val hasBig = line.split(" ")
        .mapNotNull { it.trim().toIntOrNull() }
        .any { it >= 100 }

    println(hasBig) // true
}

Notice how well today’s topics combine: first mapNotNull returns List<Int>, and only then any works with normal numbers, without Int?.

5. Common mistakes when working with mapNotNull and filterNotNull

Mistake #1: use map { toIntOrNull() } and then behave as if it were List<Int>.
This happens almost automatically: your eyes see “numbers,” your brain is happy, and the compiler reminds you that it’s Int?. In practice it’s fixed in one move: if you don’t want to work with nullable elements, turn map into mapNotNull (especially in parsing). And yes, this is exactly the case mapNotNull was designed for: “a transformation that can produce null.”

Mistake #2: expect filterNotNull() to “clean all data in general,” including empty strings, zeros, and whitespace.
filterNotNull() is not a “sanitation worker,” it’s a “bouncer with strict ID checks”: it removes only null. An empty string is still a string, so it stays. If you need to remove empty strings, that’s a separate filter like filter { it.isNotEmpty() }. Mixing these concepts is dangerous: you’ll be confident the data is clean, and then you’ll wonder why "" suddenly slipped through somewhere.

Mistake #3: write map { ... }.filterNotNull() where mapNotNull { ... } would be more logical, and thereby hurt readability.
Technically it works, but the meaning gets lost. mapNotNull reads as one coherent operation: “transform and drop null results.” When you write map().filterNotNull(), the reader first has to realize you produced nullable results, and only then see that you cleaned them. It’s not a disaster, but in learning and team projects, “meaningful readability” is half the battle.

Mistake #4: try to report parsing errors via println right inside mapNotNull.
Sometimes you want to do: “if it didn’t parse, I’ll print a warning.” You can, but that’s a side effect inside a transformation operation. The code becomes less predictable (especially if later you want to reuse this pipeline not in a CLI, but, for example, in tests). Usually it’s better to separate concerns: either return null as a “skip it” signal, or collect errors in a separate way. If you really want it — move it into a separate function, and decide there whether to print or not.

Mistake #5: use toInt() instead of toIntOrNull() on “dirty” data and hope everything will be fine.
toInt() throws NumberFormatException when given junk, while toIntOrNull() returns null, which fits perfectly with mapNotNull. This is not just “style”; it’s a fundamental difference in behavior. Kotlin explicitly shows the safe path as an alternative to exceptions: toIntOrNull() instead of crashing.

1
Task
Kotlin SELF, level 22, lesson 3
Locked
Guest list
Guest list
1
Task
Kotlin SELF, level 22, lesson 3
Locked
Cleaning up numbers
Cleaning up numbers
1
Task
Kotlin SELF, level 22, lesson 3
Locked
Store coupons
Store coupons
1
Task
Kotlin SELF, level 22, lesson 3
Locked
Search result positions
Search result positions
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION