JavaRush/Courses/Kotlin SELF/Removing null from collections and transformation pipelin...

Removing null from collections and transformation pipelines

Open

1. null in collections is like sand in bearings

Collections and pipelines (chains of map/filter/...) are usually written for readability: “take a list → clean → transform → get a result”. But if a null is sitting inside the collection, then at almost every step you get ?., ?:, or even the magical !!, which turns code into a quest: “guess where it will crash”. Kotlin forces us to be honest: a nullable type is a signal that you need to make a decision downstream.

Imagine we continue our educational console project (let’s call it ExpenseTracker): it stores expenses and builds reports. And now we decided to process a “raw import” of lines (for example, the lines came from an external source or from command history).

Let the line format be: amount;category;note, for example 120;food;pizza.

data class Expense(val amount: Int, val category: String, val note: String)

fun main() {
    val rawLines = listOf(
        "120;food;pizza",
        " ;transport;metro",
        "100; ;coffee",
        "oops;fun;bug"
    )

    println(rawLines.size) // 4
}

The problem: in real life, some lines are “crooked”. We can try to carry them forward as Expense?, but then almost every subsequent operation will be forced to remember the ?.

2. Two “bad smells”: List<T?> and a nullable pipeline

When List<T?> or Map<K, V?> appears in code, it’s not always a mistake. Sometimes it’s a normal intermediate form. But more often it’s a sign you mixed two things: processing incoming dirt and meaningful work with data. In other words, you wanted to sum totals by categories, but ended up debating philosophy: “and what does null for the third element of the list mean?”.

In Kotlin it’s especially important to notice exactly where null appeared.

List<T?> usually appears when you do map { ... }, and sometimes return null inside. Kotlin naturally supports the idea that if a transformation can return null, then downstream you either have to live with it, or explicitly remove it. There are dedicated tools for that—we’ll get to them now.

Mini illustration of “how it all starts”:

fun parseAmountOrNull(text: String): Int? =
    text.trim().toIntOrNull()

fun main() {
    val rawAmounts = listOf("10", "  ", "oops", "25")

    val parsed: List<Int?> = rawAmounts.map { parseAmountOrNull(it) }
    println(parsed) // [10, null, null, 25]
}

The list has already become List<Int?>. And if you now want to do sum(), it won’t work—and that’s correct: “sum of what?” (something and null?)—math starts nervously smoking on the sidelines.

3. filterNotNull(): remove null and get a normal type back

When you know for sure that null in a collection means “doesn’t fit / failed / skip it”, the simplest strategy is to remove null as early as possible. That’s what filterNotNull() is for: after it, the collection type changes from List<T?> to List<T>, and you can use normal operations again without ?. at every step.

This is a good place for discipline: “one function cleans, then we work with clean data”. That way null doesn’t spread through the project like water across a keyboard after a cup of tea (which definitely was standing “in a safe place”).

fun main() {
    val parsed: List<Int?> = listOf(10, null, null, 25)

    val clean: List<Int> = parsed.filterNotNull()
    println(clean)         // [10, 25]
    println(clean.sum())   // 35
}

It’s important to feel the difference here: filterNotNull() is not just a “filter”, it’s also a type change. That’s one reason it’s more convenient than filter { it != null } in a “manual style”: Kotlin understands the intention and returns a non-null collection (in the types).

4. mapNotNull(): transform and discard the bad in one step

Sometimes filterNotNull() is a second step, but you want to do it in one shot: “try to parse → if it fails, throw it away”. That’s what mapNotNull { ... } is for. It’s literally made for the scenario: “a normal map, but if the transform returned null, the element doesn’t get into the result”.

Let’s return to our expense import. We want to get List<Expense> from List<String>, skipping bad lines.

Let’s make small parser functions. They return T?, and that’s fine: we’re at the boundary of “raw input”.

fun parseNonEmptyOrNull(raw: String): String? =
    raw.trim().takeIf { it.isNotEmpty() }

fun parseIntOrNull(raw: String): Int? =
    raw.trim().toIntOrNull()

Now the full line parser:

fun parseExpenseLineOrNull(line: String): Expense? {
    val parts = line.split(";")
    if (parts.size != 3) return null

    val amount = parseIntOrNull(parts[0]) ?: return null
    val category = parseNonEmptyOrNull(parts[1]) ?: return null
    val note = parts[2].trim() // let note be even empty, but not null

    return Expense(amount = amount, category = category, note = note)
}

And the key part: mapNotNull.

fun main() {
    val rawLines = listOf(
        "120;food;pizza",
        " ;transport;metro",
        "100; ;coffee",
        "oops;fun;bug"
    )

    val expenses: List<Expense> = rawLines.mapNotNull { parseExpenseLineOrNull(it) }
    println(expenses.size) // 1
    println(expenses[0])   // Expense(amount=120, category=food, note=pizza)
}

We got exactly what we wanted: null was “inside” parsing, but didn’t make it into the domain collection. The pipeline became readable again: rawLines.mapNotNull(::parseExpenseLineOrNull) is almost like a declaration of intent.

5. The “gateway” pattern: clean the input, then work with List<T>

Beginners often feel tempted: “let it be List<Expense?>, I’ll deal with it later”. That works right up until the first report, where you suddenly write it?.amount ?: 0, then again, then again, and now you’ve accidentally computed a sum where input errors turned into zeros. And zero is not an “error”; it’s a perfectly valid number that silently breaks your statistics.

A rule that saves your sanity: make one explicit normalization/parsing stage, and then work only with clean types.

You can visualize it schematically like this:

flowchart TD
    A["Raw lines (List⟨String⟩)"] --> B["Normalization (trim, split)"]
    B --> C["Parsing (T?)"]
    C --> D["mapNotNull / filterNotNull"]
    D --> E["Clean data (List⟨T⟩)"]
    E --> F["Reports / sorting / grouping"]

Now let’s add a small practical step to our project: we’ll build a report of category totals. We already have List<Expense>.

fun totalByCategory(expenses: List<Expense>): Map<String, Int> {
    return expenses
        .groupBy { it.category }
        .mapValues { (_, items) -> items.sumOf { it.amount } }
}

fun main() {
    val expenses = listOf(
        Expense(120, "food", "pizza"),
        Expense(80, "transport", "metro"),
        Expense(30, "food", "coffee")
    )

    println(totalByCategory(expenses)) // {food=150, transport=80}
}

The key point: there isn’t a single ? here. And that’s not magic—it’s the “gateway” discipline: once we turned raw stuff into Expense, and then we live like adults (sometimes it hurts, but it’s effective).

6. Searching in collections: T? and how not to smear ?. everywhere

When you search for an element in a collection (find, firstOrNull, getOrNull), Kotlin honestly says: “it might not be found”—here’s T?. That’s normal: absence of a result is a real scenario.

And here’s the typical mistake: you found an Expense?, then you start passing it further as nullable, and three functions later you suddenly have Expense? in the very center of the program.

Instead, it’s usually better to do one of two things: either handle the absence near the search, or turn the absence into a clear value (via ?:, Result, sealed, or an early return—the choice depends on the contract).

Example: find the first expense by category.

fun findFirstByCategory(expenses: List<Expense>, category: String): Expense? =
    expenses.find { it.category == category }

fun main() {
    val expenses = listOf(Expense(120, "food", "pizza"))

    val found = findFirstByCategory(expenses, "transport")
    println(found) // null
}

And now “don’t smear null”: handle it right away.

fun printFirstExpenseNote(expenses: List<Expense>, category: String) {
    val expense = expenses.find { it.category == category } ?: run {
        println("Nothing found for category='$category'") // Nothing found for category='transport'
        return
    }

    println("Note: ${expense.note}") // (won’t run if nothing was found)
}

Notice we created a “gateway” again: expense after the Elvis (?:) becomes non-null, and the rest of the code is simpler.

By the way, if you work with indexes, getOrNull() and similar safe operations also return nullable, and that’s expected: “the element may be missing”.

7. Map<K, V?> in reports: how to get Map<K, V> without !!

Dictionaries (Map) are a place where nullable appears “naturally”: map[key] returns V? because the key might not exist. And it’s easy to accidentally end up with Map<String, Int?> in computations if you start encoding “no data” as null.

Consider an example: we want to compute the average expense by category, but if a category is empty—we’ll get null (the average is undefined). That’s exactly the case where null can be a meaningful intermediate value.

fun avgByCategoryOrNull(expenses: List<Expense>): Map<String, Int?> {
    return expenses
        .groupBy { it.category }
        .mapValues { (_, items) ->
            if (items.isEmpty()) null else items.sumOf { it.amount } / items.size
        }
}

Yes, items.isEmpty() after groupBy usually won’t happen (a group won’t be empty), but imagine the logic is more complex—the idea matters more to us here.

Now the question: how do we get a report where we show only categories with a defined average, and we want Map<String, Int>?

A “bad but popular” option:

// Don’t do this without ironclad confidence:
val clean = avgMap.filterValues { it != null }.mapValues { it.value!! }

Here !! is dangerous: you manually promise the compiler that null won’t happen. It’s better to do it “honestly” using mapNotNull over entries:

fun onlyDefinedAvgs(avgMap: Map<String, Int?>): Map<String, Int> {
    return avgMap
        .entries
        .mapNotNull { (category, avgOrNull) ->
            avgOrNull?.let { avg -> category to avg }
        }
        .toMap()
}

The idea is simple: we turn a Map into a list of pairs, where pairs are created only if the value is not null, and then we assemble it back into a Map. Yes, it’s a bit more typing—but there’s zero risk of “crashing unexpectedly” and zero hidden promises.

It’s also nice that mapNotNull here again expresses the intention: “transform, but if null—discard it”.

8. Useful nuances for pipelines

takeIf / takeUnless: control null in one place

Sometimes null is needed as an intermediate signal: for example, “the string is empty after normalization—so there’s no value”. You can write a separate if for this, but in pipelines you often want it more compact. That’s where takeIf and takeUnless help: they return the value if the condition is true (or false), otherwise they return null.

This sounds like “we create null ourselves”, and yes—but that’s the point: we create it in one clear place, where it means exactly one thing.

Example: normalize a note. If after trim() it’s empty, we consider that there is no note, and we want to drop it from a list of notes.

fun normalizeNoteOrNull(raw: String): String? =
    raw.trim().takeIf { it.isNotEmpty() }

fun main() {
    val notes = listOf("  pizza ", "   ", "coffee", "")
    val cleanNotes = notes.mapNotNull { normalizeNoteOrNull(it) }

    println(cleanNotes) // [pizza, coffee]
}

Here takeIf is a small, neat “null generator by rules”, and mapNotNull is a “vacuum cleaner” that immediately removed that null from the result. So what goes outward is List<String>, without nullable.

Mini report: top categories with no nullable in the middle of the pipeline

Now let’s build a piece that looks like real code in our ExpenseTracker: we build a top-categories-by-total report, but we want to ignore imported lines that failed to parse. Important: we do not want List<Expense?> and we do not want Map<String, Int?> at the end.

We’ll assemble everything in one place: “raw → mapNotNull(parse) → report”.

fun topCategories(rawLines: List<String>, topN: Int): List<Pair<String, Int>> {
    val expenses = rawLines.mapNotNull { parseExpenseLineOrNull(it) }

    return expenses
        .groupBy { it.category }
        .mapValues { (_, items) -> items.sumOf { it.amount } }
        .entries
        .sortedByDescending { it.value }
        .take(topN)
        .map { it.key to it.value }
}

fun main() {
    val rawLines = listOf(
        "120;food;pizza",
        "80;transport;metro",
        "oops;food;bug",
        "30;food;coffee"
    )

    val top = topCategories(rawLines, topN = 2)
    println(top) // [(food, 150), (transport, 80)]
}

Notice a nice thing: null existed only inside parseExpenseLineOrNull, and in the “business part” of the report it doesn’t exist at all. That’s the practical goal of this lecture: null should live where it’s explainable, and die where it gets in the way.

9. Common mistakes when removing null from collections and pipelines

Mistake #1: leaving List<T?> “for later”, and then writing it?.x ?: 0 in every report.
This code looks like you care about safety, but in practice you start quietly “fixing” data, turning errors into zeros, empty strings, or other default values. That breaks the meaning of reports: you no longer distinguish “no data” from “data equals zero”. Better to build a single cleanup gateway (mapNotNull/filterNotNull) and then work with List<T>.

Mistake #2: using !! after a filter because “I already checked”.
The usual scenario: you applied a filter, then later you slightly changed the condition, added another branch—and now !! suddenly became a crash point. Even if it’s “obvious” now, in a week it won’t be obvious even to you. If you filter null, do it in a way that makes the type non-null, or use ?.let { ... }, not a promise via !!.

Mistake #3: building a pipeline that creates new nullable values at every step.
For example, map { ...? } → map { it?.something } → filter { it != null } → map { it!! }. This turns into a “ladder of crutches”. Usually, either do mapNotNull right away, or split into two readable steps: val clean = ...filterNotNull() and then val transformed = clean.map { ... }.

Mistake #4: mixing “not found” and “error” into the same null.
In pipelines this is especially insidious: one null means “the line is empty”, another means “failed to parse a number”, a third means “category not found”, and all of it ends up as just null. Then you do filterNotNull() and lose information about why an element disappeared. For reports that’s fine, but for diagnostics and UX it often isn’t. If the reason matters, return Result/sealed and only then decide what to drop.

Mistake #5: cleaning null too late—after groupings and calculations.
If null gets inside groupBy, mapValues, complex sorting, and top-N logic, you’re forced to add checks in the middle of the pipeline, where they’re harder to read. Most of the time, the right approach is to clean earlier: “get List<T> as early as possible”, and only then group and aggregate.

1
Task
Kotlin SELF,  41 level,  3 lecture
Locked
Sum of readings
Sum of readings
1
Task
Kotlin SELF,  41 level,  3 lecture
Locked
Clean notes
Clean notes
1
Task
Kotlin SELF,  41 level,  3 lecture
Locked
Numbers from the log
Numbers from the log
1
Task
Kotlin SELF,  41 level,  3 lecture
Locked
Gapless Price List
Gapless Price List
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet