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 it may look “Kotlin-ish”, but there is a fundamental difference: in Java, historically, null is a normal value, and it is far from always clear from a signature whether a method can return null.
This leads to the main topic: how Kotlin learns the nullability of Java methods and collections—when it sees a clear contract (String or String?), and when it leaves platform uncertainty (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?” often lives in the library author’s head (and sometimes only in the bug tracker).
For Kotlin to be strict not only inside 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 uses them to build the types visible from 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 the contract enough that it lets you write stricter code, but if the contract is violated, the JVM can still “carry through” null—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: Kotlin tries to turn “something unclear 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, this is phrased roughly as follows: a Java type without annotations becomes a flexible type (IDE often shows it as T!), and the presence of annotations can “refine” that type toward T or T?.
Let’s fix a simple table that is handy to keep in mind:
| How it looks 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 rather “dangerous uncertainty”.
- Foo? forces you to write safe code.
- Foo forces you to live like an optimist (and sometimes crash if the optimism was misplaced).
- Foo! is the state “the compiler isn’t sure”, and sometimes it allows you to write code as if it were Foo, even though null can arrive at runtime.
Here is a small example that shows the compiler’s “psychology”: if you explicitly choose the type String, Kotlin has the right 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 is important: as soon as you choose T in Kotlin, you’re basically signing a paper that says “I’m sure it’s not null.”
3. Where @Nullable/@NotNull are placed in Java
So far we spoke abstractly: “a Java method is annotated.” But beginners often get confused at the level of “where exactly do I put the annotation and what exactly does it mean?”. Plus there is one more layer of complexity: an annotation may refer not only to the type itself, but also to type arguments (for example, list elements).
The Kotlin specification lists what can be annotated: a field (the annotation applies to the field’s 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+)—this is exactly TYPE_USE.
Imagine that somewhere in a Java library there is an interface like this (we don’t write or compile it—we just read 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 starts when Java annotates the insides of collections. For example:
interface TagService {
@NotNull List<@Nullable String> loadTags();
}
Then Kotlin sees: “the list definitely exists, but elements may be null”—that is, roughly List<String?>.
And this is not a theoretical rarity. In the Kotlin 2.x compiler documentation, an example is given that with 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 one problem. In practice there are two problems: the collection itself (the container) can be null, and there can be null values inside (the elements). These cases break code in different ways, and Kotlin makes you state both.
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 “there may be no list, and if there is a list—inside there may be nulls.”
If you see such a type (or suspect that you have a platform type that behaves this way), the most mature approach is boundary normalization: turn a “dirty external world” into a “clean internal 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]
}
Notice the style: we do not try to “drag nullable throughout the whole program.” We accept reality once at the boundary (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 declared @NotNull List<@Nullable String>, Kotlin will show you the truth as List<String?>, and you immediately understand that you need filterNotNull().
5. Embedding into a CLI: “dirty input” → “clean Kotlin model”
In the course we already have a practical console application: it stores data in collections, accepts commands, and tries to be resilient to garbage input. Now we will add a small but typical layer: “data came from the outside” (in real life this could be a Java library, a plugin, or an SDK), and we want to embed this data into domain code without spreading null across the entire project.
Imagine that somewhere “outside” we receive category names. We won’t write Java code, but we will write Kotlin as if we were given a function returning “a list of strings, possibly with nulls.” Even if it were annotated in Java, we still want a single gateway.
Let’s build a utility that turns an external list into a list of categories suitable for the 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 real life—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: we don’t debate why there is null, and we don’t try to heal the whole world. We simply build a “sanitization checkpoint”: further into the application, categories are a normal List<String>, and the 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 is useful to take off the rose-colored glasses: “annotations will save the world” sounds nice, but reality is more complex. Kotlin tries to use @Nullable/@NotNull, but it has an important compatibility rule: if annotations disappear (or the library comes without them), the code should still have a chance to compile. Therefore, in ambiguous situations 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 the type hierarchy), then enhancing may be dropped, and instead of a strict type Kotlin will take a platform type.
We need a practical takeaway:
If you see that a method from Java returns Something!, don’t try to “force the compiler by willpower.” Do the same thing we did earlier: at the boundary choose Something? and normalize the data. This works when there are no annotations, and when they exist but Kotlin didn’t apply them for some reason.
For example, this coding style is resilient:
fun safeToken(raw: String?): String? {
return raw
?.trim()
?.takeIf { it.isNotEmpty() }
}
fun main() {
val tokenFromJava: String? = System.getenv("APP_TOKEN") // came from the 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 really returned and what Kotlin managed to infer from annotations. So it is useful to train yourself to occasionally pause and look at an expression’s 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 a sign that says “Caution, surprises possible”: Kotlin isn’t sure, and the responsibility is on you again.
One more hint: compiler messages about nullability are usually quite literal. If you try to pass String? where String is expected, it will say Type mismatch. If you try to call .length on a String?, it will remind you about a safe call. It is not “scolding”—it is literally your program in the stage of “I won’t let myself step on a rake.”
7. Typical mistakes when working with @Nullable/@NotNull and Java collections
Mistake #1: assuming @NotNull is a magic spell that physically forbids null.
An annotation is a contract, not armor. Kotlin may trust that contract and give you the type T, but the JVM can still deliver null if the library is wrong. 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 problem.
Mistake #2: confusing “the list can be null” with “nulls can be inside the list.”
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 each 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: carrying platform types (T!) deep into domain logic.
If you let String! or MutableList<String!>! roam around the 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 work by 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 it is indeed the right decision (for example, the application 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 keep the reference as List<String>, the Java source could have returned a mutable collection. If stability of a snapshot matters for your scenario, make a copy via 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