1. Why Kotlin needs @Nullable/@NotNull
Kotlin on the JVM constantly interacts with the Java world: the JDK, any Java libraries, legacy code in the project. In Kotlin, this looks “Kotlin-ish”, but there is a fundamental difference: historically, in Java null is a normal value, and it is far from always clear from the signature whether a method can return null.
That leads to the main topic: how Kotlin learns nullability for Java methods and collections—when it sees a clear contract (String or String?), and when it leaves platform ambiguity (String!).
When you write Kotlin code, null-safety feels like “compiler magic”: you wrote String, and everything is safe. But as soon as you call Java code, reality shows up: in Java, null is allowed almost everywhere, and the answer to “can this method return null?” is often stored in the library author’s head (and sometimes only in the bug tracker).
For Kotlin to be strict not only within Kotlin code but also when calling Java code, it needs hints. Those hints are Java nullability annotations: @Nullable (“may be null”) and @NotNull (“null is forbidden”). Kotlin can recognize such annotations (in particular, JetBrains annotations) and use them to build the types visible in Kotlin. This is described as enhancing signatures—improving type signatures when importing Java APIs into Kotlin.
It is important to understand a subtle point: annotations in Java are not an “ironclad guarantee”; they are a contract. That is, the author promises: “I return non-null.” Kotlin trusts that contract enough that it lets you write stricter code, but if the contract is violated, the JVM can still “smuggle” null through—and then you will get a crash (at least earlier and more clearly than somewhere later in the chain).
2. How Kotlin builds types: T, T?, and T!
The short formula is this: Kotlin tries to turn “some unknown thing from Java” into a “clear Kotlin type”. It does this by prioritizing information: first it looks at annotations (@Nullable/@NotNull), and if there are none (or they conflict), it may leave a platform type (T!).
In the Kotlin specification, it is stated roughly like this: a Java type without annotations becomes a flexible type (the IDE often shows it as T!), and the presence of annotations can “refine” this type toward T or T?.
Let’s fix a simple table that is convenient to keep in mind:
| What it looks like in Java | How Kotlin usually sees it |
|---|---|
| Foo (no annotations) | |
| @Nullable Foo | |
| @NotNull Foo | |
Now the important practical part: why Foo! is not “yet another kind of nullable”, but specifically “dangerous uncertainty”.
- Foo? forces you to write safe code.
- Foo forces you to live like an optimist (and sometimes crash if the optimism was unwarranted).
- Foo! is the state where “the compiler is not sure”, and sometimes it allows you to write code as if it were Foo, even though at runtime null may arrive.
Here is a small example that shows the compiler’s “psychology”: if you explicitly choose the type String, Kotlin is allowed to insert a runtime check and “fail fast”, because you declared a strict contract (in the specification this is described as generating checks when an expected type is present).
fun main() {
val rawFromJava = System.getProperty("my.app.mode") // may be null
val mode: String = rawFromJava // may fail fast here if rawFromJava == null
println(mode.length)
}
In a correct program, we would of course “land” this into String?, but the fact itself matters: as soon as you choose T in Kotlin, it’s as if you sign a document saying “I’m sure it’s not null”.
3. Where to put @Nullable/@NotNull in Java
So far we have spoken abstractly: “a Java method is annotated.” But beginners often get stuck at the level of “where exactly do I put the annotation, and what exactly does it mean?”. Plus there is another layer of complexity: an annotation can refer not only to the type itself, but also to type arguments (for example, to list elements).
The Kotlin specification lists what can be annotated: a field (the annotation applies to the field type), a method (the annotation applies to the return type), a parameter (the annotation applies to the parameter type), and it separately mentions type-use annotations (Java 8+)—that is exactly TYPE_USE.
Imagine that somewhere in a Java library there is an interface like this (we are not writing or compiling it—just reading it as an API “passport”):
interface UserDirectory {
@NotNull String findDisplayName(@NotNull String id);
@Nullable String findEmail(@NotNull String id);
}
In Kotlin, this reads very naturally:
fun printUserInfo(dir: Any /* suppose this is UserDirectory */) {
// conceptually: displayName: String (non-nullable)
// conceptually: email: String? (nullable)
}
But the most interesting part begins when Java annotates the inside of collections. For example:
interface TagService {
@NotNull List<@Nullable String> loadTags();
}
Then Kotlin sees: “the list definitely exists, but elements can be null”—that is, roughly List<String?>.
And this is not a theoretical rarity. The Kotlin 2.x compiler documentation gives an example that for a Java signature like @NotNull ResultContainer<@Nullable String> fetchData() Kotlin carries nullability into the type argument and gets ResultContainer<String?>.
4. Collections: null in the container and null in the elements
When it comes to lists and sets, people often think that a “nullable collection” is a single problem. In practice there are two problems: the collection itself (the container) can be null, and there can be null values inside (elements). These cases break code differently, and Kotlin forces you to make both explicit.
The contrast is easy to see in types:
- List<String>? means “the list may be absent, but if it exists—strings inside are not null.”
- List<String?> means “the list exists, but inside there may be nulls.”
- List<String?>? means “the list may be absent, and if the list exists—inside there may be nulls.”
If you see such a type (or suspect you have a platform type that behaves like this in practice), the most mature approach is boundary normalization: turn a “dirty outside world” into a “clean inside world of your application”.
For normalization, there are familiar tools: ?: emptyList(), filterNotNull(), mapNotNull(). We are not learning anything “new and scary”—we are just applying basic Kotlin correctly.
Here is a small and very practical gateway function:
fun normalizeTags(raw: List<String?>?): List<String> {
return raw
?.map { it?.trim() } // prepare elements (trim)
?.filterNotNull() // drop null elements
?.filter { it.isNotEmpty() } // drop empty after trim
?: emptyList() // if there is no list, treat it as empty
}
And an example of usage:
fun main() {
val raw: List<String?>? = listOf(" kotlin ", null, " ", "jvm")
val tags = normalizeTags(raw)
println(tags) // [kotlin, jvm]
}
Pay attention to the style: we do not try to “drag nullable further through the whole program”. We accept reality at the boundary once (data is dirty), clean it, and then work with a normal List<String>.
And this is where Java annotations @Nullable/@NotNull really help: if the library honestly wrote @NotNull List<@Nullable String>, Kotlin will show you the truth as List<String?>, and you immediately understand that you need filterNotNull().
5. Wiring into a CLI: “dirty input” → “clean Kotlin model”
In this course, we already have a practical console application: it stores data in collections, accepts commands, and tries to be resilient to messy input. Now we will add a small but typical layer: “data came from outside” (in reality, this could be a Java library, a plugin, or an SDK), and we want to integrate that data into domain code without spreading null throughout the whole project.
Imagine that somewhere “outside” we receive category names. We will not write Java code, but we will write Kotlin as if we were given a function that returns a “list of strings, possibly with null”. Even if Java had it annotated, we still want a single gateway.
Let’s make a utility that converts an external list into a list of categories suitable for add/list commands:
private fun normalizeCategoryNames(raw: List<String?>?): List<String> {
return raw
?.map { it?.trim()?.lowercase() }
?.filterNotNull()
?.filter { it.isNotEmpty() }
?: emptyList()
}
Now let’s add usage in our “main loop” (or in a command handler). Let the categories command print available categories (in reality—from an external source):
fun main() {
// as if it came from a Java library:
val rawFromJava: List<String?>? = listOf(" Food ", null, "Transport", " ")
val categories = normalizeCategoryNames(rawFromJava)
println("Categories (${categories.size}):")
println(categories.joinToString()) // food, transport
}
What matters methodologically here: we are not discussing why there is null there, and we are not trying to fix the whole world. We are just building a “sanitization checkpoint”: further in the app, categories are a normal List<String>, and command code does not turn into an endless festival of ?.
If tomorrow the external source becomes more honest and starts returning @NotNull List<@NotNull String>, then the Kotlin type will become even stricter, but our sanitization checkpoint will still remain correct (it will just become slightly redundant).
6. Why Kotlin leaves T!
It helps to take off the rose-colored glasses: “annotations will save the world” sounds nice, but reality is more complicated. Kotlin tries to use @Nullable/@NotNull, but it has an important compatibility rule: if annotations disappear (or the library ends up without them), code should still have a chance to compile. Therefore, in ambiguous cases Kotlin may “fall back” to a platform type and possibly show a warning.
The specification describes this directly: if an “enhanced” signature is not compatible (for example, it conflicts with signatures in a type hierarchy), then enhancing may be discarded, and instead of a strict type Kotlin will use a platform type.
We need a practical takeaway from this:
If you see that a Java method returns Something!, do not try to “pressure the compiler by willpower”. Do the same thing we did earlier: at the boundary choose Something? and normalize the data. This works both when there are no annotations and when they exist but Kotlin did not apply them for some reason.
For example, this style of code is resilient:
fun safeToken(raw: String?): String? {
return raw
?.trim()
?.takeIf { it.isNotEmpty() }
}
fun main() {
val tokenFromJava: String? = System.getenv("APP_TOKEN") // came from outside
val token = safeToken(tokenFromJava)
println(token ?: "<no token>") // <no token>
}
How to see String, String?, and String! in IntelliJ IDEA
The IDE is your X-ray: it tells you what Java code actually returned and what Kotlin could infer from annotations. So it is useful to build the habit of occasionally stopping and looking at the expression type.
In IntelliJ IDEA it is usually enough to hover over an expression or a variable.
- If you see String, it means somewhere the contract guarantees non-null.
- If you see String?, it means someone honestly said “may be null” (and you must handle it).
- And String! is the sign “Caution: surprises possible”: Kotlin is not sure, and the responsibility is on you again.
One more hint: compiler messages about nullability are usually quite literal. If you try to pass a String? where a String is expected, it will say Type mismatch. If you try to call .length on a String?, it will remind you about safe calls. It is not “scolding you”—it is literally your program in the stage of “I’m not letting myself step on a rake”.
7. Typical mistakes when working with @Nullable/@NotNull and Java collections
Mistake #1: thinking that @NotNull is a magic spell that physically forbids null.
An annotation is a contract, not armor. Kotlin can trust that contract and give you a T type, but the JVM can still deliver null if the library made a mistake. So even with @NotNull it is important to understand where you trust the outside world and where you want to “fail fast” and diagnose the issue.
Mistake #2: confusing “the list may be null” with “elements inside the list may be null”.
List<String>? and List<String?> break code differently. In the first case you cannot even iterate without checking the container; in the second you can iterate, but you must decide the fate of every element. With Java integrations it is easy to get both risks at once, and then you need a careful sanitization checkpoint: ?: emptyList() plus filterNotNull().
Mistake #3: dragging platform types (T!) deep into domain logic.
If you let String! or MutableList<String!>! roam around your project, “a sudden NPE” becomes a matter of time. It is much cheaper to decide once at the boundary: “is this nullable or not?”, and then follow normal Kotlin rules.
Mistake #4: “fixing” everything with !! because it is faster.
!! is not an integration tool; it is an emergency button “crash the program here”. Sometimes that is indeed the correct decision (for example, the app cannot work without a value), but when processing external data you more often need a fallback (?:), normalization (trim(), takeIf()), and gentle behavior (an empty list instead of a null list).
Mistake #5: expecting that List in Kotlin makes data immutable if the source is Java.
Even if you hold the reference as List<String>, a Java source might have returned a mutable collection. If stability of the snapshot matters for your scenario, make a copy with toList() after normalization. This is not “paranoia”; it is a way to make behavior predictable, especially when data comes from someone else’s code.
GO TO FULL VERSION