JavaRush /Courses /Kotlin SELF /Generics on collections + minimal data structure for a pr...

Generics on collections + minimal data structure for a practical project

Kotlin SELF
Level 19, Lesson 4
Available

1. Why generics are needed and how to read collection types

If you are just starting out, generics are easy to perceive as “those weird angle brackets that make life harder.” But in practice, this is your insurance against silly mistakes. Kotlin really can infer types, but type inference is not telepathy: the compiler relies on what you give it. If you store data in collections, it is important that the compiler knows exactly what is inside; otherwise it cannot protect you from accidentally mixing unrelated values.

Imagine a kitchen jar labeled “Sugar.” Generics are that label. You can pour anything into it, but if the label says “Sugar,” at least you will not try to scoop “a couple of bolts” into your tea. And conversely: if you do not label the jar, one day you will find a flash drive in your porridge, because “well, it somehow got in there too.”

In Kotlin, the label looks like this: List<String>, MutableList<Int>, Map<String, Int>. And this is not just decoration—it is a contract that the compiler enforces for you. The general idea of generics in Kotlin is described as “generic type parameters” (type parameters).

Now comes a moment when your brain may try to “mute the audio,” so let’s agree: we read generics not as “symbols,” but as an English phrase. You see List<T>—you say: “a list of elements of type T.” You see Map<K, V>—you say: “a map from a key of type K to a value of type V.” After a couple dozen repetitions, this becomes automatic—roughly like reading the word println.

Kotlin collections have interfaces and a hierarchy, but today what matters more is the type contract: the container stores elements of a specified kind. Roughly:

  • List<T> stores elements in order and provides index-based access.
  • Set<T> stores unique elements.
  • Map<K, V> stores “key → value” pairs.

A small “how to read it” table

Type notation How to read it Example values
List<String>
a list of strings
["milk", "bread"]
MutableList<Int>
a mutable list of integers
[10, 20, 30]
Set<String>
a set of unique strings
{"kotlin", "jvm"}
Map<String, Int>
a map “string → integer”
{"tea" -> 90}

And yes, this is not about what is “actually inside”, but about what you are allowed to put in and take out.

Type inference: why it’s easy with a non-empty collection, but not with an empty one

One of the most common beginner situations: “I created an empty list, and Kotlin complains. But it’s smart.” Kotlin is smart, but it is not a fortune teller. If you write mutableListOf() with no elements, the compiler sees zero values and cannot know what T you meant. And that is fair: a list “of what exactly”—strings, numbers, triple records, kittens.

With non-empty collections, everything is simpler: Kotlin sees the elements and infers the type from them. This is standard type inference behavior: you can omit type arguments if they are unambiguously inferred from the context.

Example: the type is inferred automatically because there are elements

fun main() {
    val names = listOf("Ann", "Bob")    // List<String>
    val nums = mutableListOf(1, 2, 3)   // MutableList<Int>

    println(names) // [Ann, Bob]
    println(nums)  // [1, 2, 3]
}

Example: empty collection—you need to provide the type

fun main() {
    val tags = mutableSetOf<String>()
    tags.add("kotlin")

    println(tags) // [kotlin]
}

Why is everything fine here? Because you specified <String>, and the compiler calmed down.

Two ways to provide the type

Sometimes it’s more convenient to write the type on the right:

fun main() {
    val ids = mutableListOf<Int>()
    ids.add(10)

    println(ids) // [10]
}

Sometimes—on the left, especially if the type is long:

fun main() {
    val ids: MutableList<Int> = mutableListOf()
    ids.add(10)

    println(ids) // [10]
}

Both variants work the same way; choose the one that makes the code more readable.

2. The most important truth about Map: map[key] returns V?

With List, everything is usually intuitive: the index either exists, or you get an error and quickly understand what you did wrong. But Map behaves differently, because a key can be missing—and that is a completely normal situation. Therefore, when reading by key, Kotlin gives you not V but V?. Otherwise, you would constantly get crashes in places where you could use polite logic instead.

Example: reading returns Int?

fun main() {
    val prices: Map<String, Int> = mapOf("tea" to 90)

    val teaPrice: Int? = prices["tea"]
    val milkPrice: Int? = prices["milk"]

    println(teaPrice)  // 90
    println(milkPrice) // null
}

Elvis ?: as a fallback plan

fun main() {
    val prices = mapOf("tea" to 90)

    val milkPrice = prices["milk"] ?: 0
    println(milkPrice) // 0
}

And this is not a “hack.” This is a normal, honest contract: “there may be a value, or there may be null.”

When containsKey is more appropriate than ?:

Sometimes you want to distinguish the situation “no key” from “the value for the key is 0.” Then it makes more sense to check for the key first:

fun main() {
    val stock = mapOf("apple" to 0)

    val key = "banana"
    if (stock.containsKey(key)) {
        println("Key exists, value = ${stock[key]}") // won't run
    } else {
        println("No such key") // No such key
    }
}

3. Nested generics without pain: how not to kill readability

At this point, many people start writing types that make you want to call a priest and “cleanse the project of demons”: MutableMap<String, MutableList<Triple<String, String, Int>>>. It is legal, but readability suffers. What matters now is learning two techniques: first, do not be shy about intermediate vals; second, make types noticeable in code through good variable names.

That means our task is not “to write the type,” but “to write the type so that people are not afraid of it.”

Poor readability, but it compiles

fun main() {
    val data: MutableMap<String, MutableList<Triple<String, String, Int>>> = mutableMapOf()
    println(data) // {}
}

It looks like a formula from organic chemistry. Maybe it’s correct, but you don’t want to “drink” it.

Better: split into meaningful pieces

fun main() {
    val byCategory: MutableMap<String, MutableList<Triple<String, String, Int>>> = mutableMapOf()
    val foodRecords: MutableList<Triple<String, String, Int>> = mutableListOf()

    foodRecords.add(Triple("food", "coffee", 300))
    byCategory["food"] = foodRecords

    println(byCategory) // {food=[(food, coffee, 300)]}
}

We have not yet learned “nice collection operations,” so we use the basics: create, add, assign by key.

4. Minimal data structure for the practical project

Now we will build a data “skeleton” for the project so that it is already useful, but does not require classes or complex architecture. Imagine we are writing a console mini expense tracker. Each record has a category, a description, and an amount. This fits perfectly into Triple<String, String, Int>: three fields, a simple contract. Yes, Triple is not the most readable format in large projects, but for a start it is a fine temporary tool—if you consistently keep the field order.

In parallel, we need a container where many records will be stored. For this, MutableList<Record> works, where Record is our Triple<...>.

Step 1: agree on the record shape

fun main() {
    val record = Triple("food", "coffee", 300)

    println(record.first)  // food
    println(record.second) // coffee
    println(record.third)  // 300
}

Yes, first/second/third are not a readability dream, but they will do for now.

Step 2: record storage in a list

fun main() {
    val records: MutableList<Triple<String, String, Int>> = mutableListOf()

    records.add(Triple("food", "coffee", 300))
    records.add(Triple("transport", "taxi", 1200))

    println(records.size) // 2
}

MutableList in Kotlin is the standard mutable list that supports add/remove and other mutation operations.

Step 3: add an identifier without classes

A very common requirement is to be able to refer to a record by id. If we had classes, we would add an id field. But we are still without OOP, so we use Map<Int, Record>.

It is important to remember the Map contract: keys are unique, and access by key can return null.

fun main() {
    val byId: MutableMap<Int, Triple<String, String, Int>> = mutableMapOf()

    byId[1] = Triple("food", "coffee", 300)
    byId[2] = Triple("transport", "taxi", 1200)

    val found = byId[2]
    println(found) // (transport, taxi, 1200)
}

Step 4: minimal in-memory project state

Now let’s put it all together: a list of records (to preserve insertion order) and a map by id (to find quickly). Yes, this is two stores, and yes—it is manual synchronization discipline. But we are building a minimal version without architectural complications.

fun main() {
    var nextId = 1
    val records: MutableList<Pair<Int, Triple<String, String, Int>>> = mutableListOf()
    val byId: MutableMap<Int, Triple<String, String, Int>> = mutableMapOf()

    val id = nextId++
    val record = Triple("food", "coffee", 300)

    records.add(id to record)
    byId[id] = record

    println(records) // [(1, (food, coffee, 300))]
}

Here we use to (an infix function) to create a Pair, so the record looks like “id to record.” This reads almost like plain English: “id corresponds to record.”

Generics as API design: List<T> vs MutableList<T>

At this point, a subtle but very practical principle appears: if a function should not modify a collection, it is better to accept a read-only type (List, Map, Set) instead of the mutable version. This makes the code safer: you immediately communicate “I only read here.” In Kotlin, this is the standard idea of separating read-only and mutable collections.

Let’s show it with a small example: we have a function that prints records. It does not need to be able to add/remove; it only needs to iterate and output.

fun printRecords(records: List<Pair<Int, Triple<String, String, Int>>>) {
    for ((id, record) in records) {
        val category = record.first
        val title = record.second
        val amount = record.third

        println("#$id [$category] $title = $amount")
    }
}

fun main() {
    val records = mutableListOf<Pair<Int, Triple<String, String, Int>>>()
    records.add(1 to Triple("food", "coffee", 300))

    printRecords(records)
    // #1 [food] coffee = 300
}

Notice: we store data in a MutableList because we will add records, but we pass it into the function as a List because the function is “read-only.”

Key normalization: a habit that saves your Map

A map (Map) is a great tool—until you start using “dirty strings” from user input as keys. The user types " Food ", then "food", then "FOOD", and your program suddenly decides these are three different categories. And the program is not at fault: you gave it three different strings.

So let’s lock in a simple habit: before using a string as a key, normalize it. Here, trim() and lowercase() are enough.

fun normalizeKey(raw: String): String {
    return raw.trim().lowercase()
}

fun main() {
    val categories: MutableSet<String> = mutableSetOf()

    categories.add(normalizeKey(" Food "))
    categories.add(normalizeKey("food"))
    categories.add(normalizeKey("FOOD"))

    println(categories)      // [food]
    println(categories.size) // 1
}

This is a good example of how Set (uniqueness) and string normalization together produce predictable behavior.

5. Common mistakes

Mistake #1: “Kotlin is smart, let it figure it out”—creating empty collections without a type.
An empty collection contains no elements, which means the compiler has nothing to infer T from. As a result, you get a type inference error. The fix is simple: specify the type explicitly either via <T> on the factory (mutableListOf<String>()), or via the type on the left (val xs: MutableList<String> = mutableListOf()).

Mistake #2: confusing Map<K, V> with the result of map[key].
Even if the value type in a Map is non-nullable (V), reading by key returns V? because the key may be missing. If you forget this and try to use the result as “definitely not null,” you will quickly hit a compile-time error (best case) or start abusing !! (worst case). A normal strategy is ?: or an explicit key-existence check.

Mistake #3: creating monstrous nested generic types without trying to make them readable.
A type like MutableMap<String, MutableList<Triple<String, String, Int>>> may be correct, but it is hard to read, especially for a beginner (and for you a week later). Intermediate variables with human-friendly names and small steps help: first the list, then the map, then the relationship between them.

Mistake #4: passing MutableList and MutableMap into every function “just in case.”
This breaks responsibility boundaries: any function suddenly has the right to “accidentally delete half the data.” If a function should only read, accept read-only types: List/Map/Set. Kotlin intentionally separates collection interfaces into read-only and mutable so you can write more predictable code.

Mistake #5: using raw user input as a key without normalization.
If you do not do trim().lowercase(), then "food", " Food ", and "FOOD" become different keys. The program looks “broken,” although in reality it is just taking the string too literally. Make normalization a habit—and Map will start behaving like a tool instead of a riddle generator.

1
Task
Kotlin SELF, level 19, lesson 4
Locked
Cart at startup
Cart at startup
1
Task
Kotlin SELF, level 19, lesson 4
Locked
Tags without chaos
Tags without chaos
1
Task
Kotlin SELF, level 19, lesson 4
Locked
Price guide
Price guide
1
Task
Kotlin SELF, level 19, lesson 4
Locked
Expenses by folders
Expenses by folders
1
Survey/quiz
Collections `List/Set/Map`, 19 level, 4 lesson
Unavailable
Collections `List/Set/Map`
Collections `List/Set/Map`
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION