JavaRush /Courses /Kotlin SELF /Star-projection *: List<*>

Star-projection *: List<*>

Kotlin SELF
Level 36, Lesson 3
Available

1. Getting to know List<*>

Imagine real life (and a real project): you are writing a logging function or a debug-print function. It does not matter at all what the list contains—Expense, String, or Int. What matters is iterating over the elements and printing them. Or, for example, showing the size, the first few elements, and then staying politely silent so you do not spam the console.

If you try to make a “universal” function via List<Any?>, you will hit the fact that yes, List is covariant, but as soon as you want to support mutable collections (MutableList) or containers with more complex type parameters, unpleasant limitations start to appear. That is why Kotlin provides a separate syntax: List<*>, which is read as:

“This is a list of elements of some type, but which one exactly is unknown.”

And this “unknown” is not philosophy, but specific compiler rules: what you can do with such a list, and what you cannot.

2. What you can do with List<*>

If we have List<*>, we can do everything “read-only”: size, isEmpty(), iterate with for, access elements by index. But there is a price: the element type becomes as safe as possible—Any?.

Mini example: “print everything”:

fun printAll(xs: List<*>) {
    for (x in xs) {
        println(x) // x: Any?
    }
}

fun main() {
    printAll(listOf(1, 2, 3))
    // 1
    // 2
    // 3

    printAll(listOf("a", "b"))
    // a
    // b
}

Why Any? and not Any? Because Kotlin allows lists where elements can be null (for example, List<String?>). When the type is unknown, the compiler must also keep that possibility in mind.

Now an important practical point: if you pull an element out of List<*>, then you work with it as Any?. Want to call string methods? First, check the type.

fun printStringLengths(xs: List<*>) {
    for (x in xs) {
        if (x is String) {
            println("${x} -> len=${x.length}") // smart cast to String
        }
    }
}

fun main() {
    printStringLengths(listOf("Kotlin", "is", "fun"))
    // Kotlin -> len=6
    // is -> len=2
    // fun -> len=3
}

Here Kotlin behaves very nicely: after if (x is String) it performs a smart cast, and x.length becomes legal.

3. Why you cannot add to MutableList<*>

With List<*>, everything looks almost like a “universal list for reading.” And beginners often make the following logical jump:

“OK, if List<*> is a list of unknown elements, then MutableList<*> is a mutable list of unknown elements. So I can add something there… at least null…”

And this is where Kotlin switches into “strict bouncer mode”: you cannot add.

fun tryToAdd(xs: MutableList<*>) {
    // xs.add(1)    // Compilation error
    // xs.add("hi") // Compilation error
    // xs.add(null) // Also a compilation error
    println(xs.size)
}

Why so strict? Because the real list could be, for example, MutableList<String>. If the compiler allowed you to add an Int, you would break the contract of a list of strings. And if it allowed you to add null, you would break MutableList<String> (where null is forbidden).

At the level of Kotlin’s formal model, star-projection is described like this: if a type parameter is unknown, then reading becomes “like out ...”, and writing becomes “like in Nothing” (that is, “nothing can be safely written”). This is stated directly in the star-projection rules: for an invariant parameter Foo<T>, the asterisk behaves like safe reading of the upper bound and like a ban on writing via Nothing.

Intuitively, you can remember it like this: MutableList<*> is a “box labeled: UNKNOWN CONTENTS”. If you start putting items in it, you risk ending up with a “box for spoons” that suddenly contains a screwdriver. The box will survive, but your type contract will not.

Comparison: * is not “Any”

A very common confusion: “the asterisk is Any.” No. Sometimes the read behavior is similar, but the meaning is different.

Let’s lock in the difference with a small table (and we will use plain language, without diving into formal type theory):

Type What it promises to the calling code What you can read What you can add
List<*>
“it’s a list, but the element type is unknown”
Any?
(not applicable,
List
is read-only)
List<Any?>
“it’s a list whose elements are definitely Any?”
Any?
(not applicable)
MutableList<*>
“it’s a mutable list, but the element type is unknown”
Any?
nothing (forbidden by the compiler)
MutableList<Any?>
“it’s a mutable list that stores Any?”
Any?
you can add Any? (including null)

So MutableList<Any?> is a “container for anything.” And MutableList<*> is a “container for who-knows-what,” which is exactly why writing is forbidden.

Let’s verify with a short example why MutableList<*> is convenient as an input parameter when you do not plan to write:

fun printFirst(xs: MutableList<*>) {
    val first = xs[0]
    println("first=$first") // first: Any?
}

fun main() {
    val names: MutableList<String> = mutableListOf("Ann", "Bob")
    val nums: MutableList<Int> = mutableListOf(10, 20)

    printFirst(names) // first=Ann
    printFirst(nums)  // first=10
}

Here MutableList<*> is useful: the function can accept any MutableList<T>, but it promises “I only read.” And the compiler guarantees that you will not accidentally start adding “the wrong thing.”

What would break without the ban on add

Let’s imagine for a second that Kotlin allowed this:

fun breakWorld(xs: MutableList<*>) {
    xs.add(123) // suppose this is allowed (in reality, it is not)
}

Now we call:

val names: MutableList<String> = mutableListOf("Ann", "Bob")
breakWorld(names)
println(names[2].length) // and what is this?

If add(123) were allowed, then names would no longer be “a list of strings” in reality. And then you would get either a runtime crash (for example, ClassCastException somewhere far away) or, even worse, a silent logical bug.

That is exactly why Kotlin prefers “better not compile than break for the user.” And MutableList<*> means: “the type is unknown; we do not have the right to write.”

4. Practice: debug-printing a list in an Expense Tracker

So this does not remain pure theory, let’s integrate the List<*> idea into our console Expense Tracker (the one where we store expenses and show reports). From time to time we want to quickly see “what is currently inside the collection” without creating ten different functions.

Let’s make a small “print a list” utility (a regular function, no extensions):

data class Expense(val title: String, val amount: Double)

fun printList(label: String, xs: List<*>) {
    println("== $label (size=${xs.size}) ==")
    for (x in xs) println(x)
}

fun main() {
    val expenses = listOf(
        Expense("Coffee", 3.5),
        Expense("Book", 12.0)
    )
    val tags = listOf("food", "education")

    printList("Expenses", expenses)
    // == Expenses (size=2) ==
    // Expense(title=Coffee, amount=3.5)
    // Expense(title=Book, amount=12.0)

    printList("Tags", tags)
    // == Tags (size=2) ==
    // food
    // education
}

Notice an important effect: printList cannot “know” anything about the element type, and that is fine. It does not claim to be business logic; it is a printing utility. In such places, List<*> is a perfect match.

Preview: print the first N elements

Now let’s add a bit more usefulness: print the first N elements so a huge list does not “kill” the console.

fun printPreview(label: String, xs: List<*>, limit: Int) {
    val n = if (xs.size < limit) xs.size else limit
    println("== $label (showing $n of ${xs.size}) ==")
    for (i in 0 until n) println(xs[i])
}

fun main() {
    val nums = listOf(1, 2, 3, 4, 5)
    printPreview("Numbers", nums, limit = 3)
    // == Numbers (showing 3 of 5) ==
    // 1
    // 2
    // 3
}

Notice how naturally this reads: we do not know the element type—and we do not need to, because the task “debug printing” does not require any operations beyond printing.

5. Checking is List<*> and inspecting elements

In real code, sometimes you receive Any (for example, from a very generic API, from a JSON tree, from Map<String, Any?>, from an external library). And you want to understand: is it a list or not?

Kotlin allows a check like x is List<*>. This is an important point: the check “this is a list of unknown elements” is valid, and Kotlin explicitly gives such an example, noting that the elements then have type Any?.

Let’s write a mini function:

fun describeValue(x: Any?) {
    if (x is List<*>) {
        println("This is a list, size=${x.size}") // ok
    } else {
        println("This is not a list")
    }
}

fun main() {
    describeValue(listOf(1, 2, 3)) // This is a list, size=3
    describeValue("hello")         // This is not a list
}

And now a typical next step: “if it is a list, let’s try to extract strings from it.” Here it is important to remember that the elements are Any?, so you will have to filter via type checks.

fun printOnlyStrings(x: Any?) {
    if (x !is List<*>) return

    for (item in x) {
        if (item is String) {
            println(item.uppercase())
        }
    }
}

fun main() {
    printOnlyStrings(listOf("a", 1, "b", null))
    // A
    // B
}

This looks a bit more verbose than “magic casting,” but it works predictably and does not turn your code into a minefield.

6. out/in projections and *

It is very useful to keep in mind the difference between “I know what the elements are, but I want to restrict operations” and “I do not know what the elements are at all.”

In MutableList<out Number> you know it is “a list of some numbers,” so you can safely read as Number, but you cannot add (because which numbers exactly are unknown). In MutableList<in Int> you know it is safe to add Int, but when reading you can only read in the most general way (usually as Any?).

And MutableList<*> is “I know nothing”: neither an upper bound beyond Any? nor a lower bound for writing. So it is maximally strict about writes.

You can picture it as an “information slider”:

flowchart LR
    A["MutableList⟨Any?⟩
(I know everything: I can read Any? and write Any?)"] --> B["MutableList⟨out Number⟩
(I know the upper bound: I read Number, I do not write)"] --> C["MutableList⟨*⟩
(I know almost nothing: I read Any?, I do not write)"]

Yes, this is a simplification (like all good teaching explanations), but it works great as intuition: the less you know about the type, the fewer things Kotlin allows you to do.

7. Common mistakes when working with star-projection *

Mistake #1: thinking that List<*> is “a list of Any” and immediately calling methods on elements.
When you write val x: Any? = xs[0], and then try to do x.length, the compiler quite reasonably does not understand why you decided it is a string. The correct approach is a type check (if (x is String)) and only then working with string properties.

Mistake #2: trying to add elements to MutableList<*> and being surprised that you cannot even add null.
This is one of the most “annoying” moments for a beginner because it feels like null is universal. But MutableList<*> means that for writes it follows logic at the level of “in Nothing,” i.e., you cannot write anything in a type-safe way (and null does not fit either). If you need to add elements, then the function contract must use a concrete “input” type, e.g., MutableList<in Int> or MutableList<Any?>, not *.

Mistake #3: confusing MutableList<*> with MutableList<Any?> and choosing the asterisk “just in case.”
The asterisk is not “more universal,” it is “stricter.” It is perfect for “read/iterate/print” scenarios, but it is inconvenient where you want to put something inside. If your function is supposed to fill a list, then you are not in the * world—you are in the in world.

Mistake #4: doing unsafe casts (as MutableList<String>) instead of using an asterisk, just to “make it work.”
Sometimes you really want to “bully the compiler”: write as and move on. But with generics this often ends with the program compiling and then crashing at runtime in an unexpected place. List<*> and MutableList<*> are useful because they let you honestly say: “I don’t know the type,” and the compiler itself will prevent you from doing dangerous operations.

Mistake #5: expecting that the check x is List<*> will automatically give you knowledge about the element types.
That check only answers the question “is this a list or not.” After it, the elements will still be Any?, and that is normal. If you need to understand whether the list contains strings, numbers, or your Expense, you will have to check the elements (is String, is Int, is Expense) or design a different function contract where the type is known in advance.

1
Task
Kotlin SELF, level 36, lesson 3
Locked
Debug printout
Debug printout
1
Task
Kotlin SELF, level 36, lesson 3
Locked
Quick preview
Quick preview
1
Task
Kotlin SELF, level 36, lesson 3
Locked
Comment lengths
Comment lengths
1
Task
Kotlin SELF, level 36, lesson 3
Locked
Quick overview
Quick overview
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION