JavaRush /Courses /Kotlin SELF /Why the compiler forbids dangerous code

Why the compiler forbids dangerous code

Kotlin SELF
Level 11, Lesson 2
Available

1. null is not “empty”, it is “no object”

When you’re just starting to program, it’s very tempting to want “absence” to behave like a regular value—for example, for null to be something like an empty string "" or the number 0. But null in Kotlin (and on the JVM in general) means something else: there is no object, which means it has no properties and no methods. It’s like trying to call a person who… has no phone. The number isn’t “empty”—the phone physically does not exist.

At runtime, the problem looks like this: if you try to access a property or call a method on null, you’ll get a runtime error (on the JVM—a typical NullPointerException). Kotlin tries to prevent this situation ahead of time, at compile time.

To feel the difference, let’s compare “an empty string” and “no string”:

fun main() {
    val s1: String = ""
    val s2: String? = null

    println(s1.length) // 0
    println(s2)        // null
}

With s1, the string object exists; it just has length zero. With s2, there is no object at all—so talking about “length” makes no sense.

The danger appears when accessing via the dot .

In Kotlin (as in many languages) the dot . means: “take the object’s property or call its method”. It’s so familiar that we do it automatically: name.length, text.uppercase(), number.toString(). And as long as the type is not nullable, everything is fair: the object exists, so you can access it.

But as soon as the value to the left of the dot has type T?, you get a fork: the object may exist, or it may be null. And the dot . is ruthless: it can’t “politely ask” whether the object exists. It just tries to access it.

Here’s a simple example that Kotlin will not let you compile:

fun main() {
    val text: String? = null

    println(text.length) // Compile error: text can be null
}

Why is the compiler against it? Because the string text can be null by contract, and null has no length. And if Kotlin stayed silent, the program could crash at runtime.

It’s important to catch the idea: the danger appears not because of null itself, but because of the “member access” operation (.), which assumes an object exists.

Type T? means “there are two scenarios”

In Kotlin, the type String? is like a sign on a door: “there may be a person in the room, or there may be no one”. If you want to talk to someone (call a method or read a property), you must first make sure someone is there. Kotlin forces you to do this, because otherwise you’ll get a runtime error—often at the worst possible moment.

A typical scenario that looks “innocent” but is actually dangerous is parsing numbers:

fun main() {
    val a: Int? = "6".toIntOrNull()
    val b: Int? = "7".toIntOrNull()

    println(a * b) // Compile error: a and b can be null
}

Here the compiler sees the following: a is not an Int, it is an Int?. So instead of “definitely a number” we have “either a number, or no number”. And arithmetic is defined for numbers, but not for “possibly missing numbers”. That’s why multiplication is forbidden until you decide what to do in the null case.

2. Where nullable values come from in console tasks

User input and toIntOrNull()

Right now we have no collections, no classes, no complex APIs. But even in small console programs null already shows up constantly—because of input. A user can type anything, including “forty twoooo”, an empty string, spaces, or sudden philosophy instead of an age.

That’s exactly why toIntOrNull() showed up in previous lectures: it’s honest—“if this looks like a number, I’ll return a number; otherwise I’ll return null”.

Let’s look at a minimal example:

fun main() {
    val text = readln().trim()
    val age: Int? = text.toIntOrNull()

    println(age) // if you type "42" -> 42, if "oops" -> null
}

The risk here is not that age equals null. The risk begins the moment we want to do something with age “as a number”: compare it, add to it, multiply it. The compiler stops us earlier than the program can crash, and requires: “handle the null case”.

How nullable “spreads” through the code

In practice, the unpleasant feeling appears like this: you did everything right, used toIntOrNull(), got an Int?, and… suddenly half the operations are “not allowed”. This is not “Kotlin being nasty”, it’s logic: if a value can be missing, almost any next step must account for that absence.

For example, you want to compute how old someone will be next year:

fun main() {
    val ageText = readln().trim()
    val age: Int? = ageText.toIntOrNull()

    val nextYearAge = age + 1 // Error: age can be null
}

It’s as if the compiler says: “I’m not against math, I’m against math with emptiness”. And if you think like a human, it’s right: if the age didn’t parse, what does it mean to “add 1”?

Here it’s important to learn to ask yourself a practical question: what should the program do if there is no value? There are usually two options:

  • either we consider the input invalid and report an error;
  • or we choose some default behavior (but this requires caution, because “default age 0” is a strange idea in real life).

In this lecture we’ll solve it the first way: an explicit check and a clear message.

3. How to protect yourself without special operators for null

An explicit if check (x == null)

For now we are not introducing special operators for null (they will be in the next lecture), so we act as directly and “honestly” as possible: write an if, check for null, and do something meaningful in each branch.

Here is an example where we either print the result or explain the error:

fun main() {
    val aText = readln().trim()
    val bText = readln().trim()

    val a: Int? = aText.toIntOrNull()
    val b: Int? = bText.toIntOrNull()

    if (a != null && b != null) {
        println(a * b) // if you type 6 and 7 -> 42
    } else {
        println("You need to enter two integers.")
    }
}

Notice an important detail: we don’t try to “carry” null further through the program. We decide what to do as close as possible to where it’s used.

An early return keeps code flat

When there are more checks, the code can easily turn into an “if ladder”, where everything shifts to the right, as if the file is slowly sliding off the table. That’s why a useful technique is an early exit: if the data is bad, we quickly end the scenario (or return from the function), and the “main” logic stays without extra nesting levels.

While we write in main, it looks like this:

fun main() {
    val ageText = readln().trim()
    val age: Int? = ageText.toIntOrNull()

    if (age == null) {
        println("Age must be a number.")
        return
    }

    println("In a year you will be ${age + 1}.") // input 20 -> In a year you will be 21.
}

Notice how readable it is: first a “guard check”, then normal logic. And the compiler is happy too, because after return we only have the scenario where the age definitely exists.

Mini questionnaire: a “soft” age without crashes

So the examples aren’t just a set of separate snippets, let’s keep developing the same app idea: a small console questionnaire. Before, it could just ask for a name and an age. Now we’ll make it more robust: the age may be entered incorrectly, and the program must not crash.

Let’s make a small function that prints a “nice greeting” if the age is valid, or explains the error if not. (We already had functions in previous lectures, so nothing supernatural here.)

fun greetUser(name: String, ageText: String) {
    val age: Int? = ageText.trim().toIntOrNull()
    if (age == null) {
        println("Hi, $name! I couldn't understand the age: '$ageText'.")
        return
    }
    println("Hi, $name! You are $age years old.") // for example: Hi, Alex! You are 25 years old.
}

fun main() {
    val name = readln().trim()
    val ageText = readln()
    greetUser(name, ageText)
}

4. Cheat sheet and diagram: how the compiler thinks

What the compiler will require you to prove

Sometimes it helps to treat T? as a type “with a fork”. Then the compiler isn’t “getting in the way”—it just forces you to choose a route.

A small table to reinforce the logic:

What you have What you want to do Why it’s dangerous/safe What the compiler will require
String
text.length
the string object definitely exists nothing; it’s safe
String?
text.length
text can be null; null has no length handle null (for example, if)
Int?
age + 1
age may be missing; “add 1 to absence” is not defined handle null
Int? after a check
age + 1
in the current branch, the value exists the compiler will allow it

It’s also useful to remember that Kotlin won’t guess meaning for you. If you don’t decide what to do when null happens, the language won’t invent “well, let it be 0”. It will honestly ask you to write that explicitly.

Diagram: got T? → compiler stops you → choose behavior

Sometimes it’s easier for students to remember not rules, but a route. Here’s the route as a simple flowchart:

flowchart TD
    A["Got a value of type T?"] --> B["Want to call a method/read a property (.) or perform an operation"]
    B --> C{"Can the value be null?"}
    C -->|Yes| D["The compiler forbids direct use"]
    D --> E["Write handling: if (x == null) ... else ..."]
    C -->|No| F["The operation is safe; the compiler allows it"]

Yes, it looks like bureaucracy. But in practice it saves hours of debugging, because “it crashed somewhere over there” turns into “it doesn’t compile right here”, and that’s usually much nicer.

5. Common mistakes

Mistake №1: treating the compiler message as “the language is broken” instead of a hint about logic.
When Kotlin says you can’t call .length on a String?, it isn’t nitpicking syntax. It literally says: “you haven’t decided what to do if there is no string”. The best approach is not to argue with the compiler, but to answer the question: “what behavior is correct when null happens right here in the program?”

Mistake №2: trying to push null further and “figure it out later”.
Often you want to do: “let age be Int?, and later I’ll somehow…”—and then everything starts falling apart with type errors. Usually it’s simpler and cleaner to handle null closer to the source: parse → check → either work with a normal value, or end the scenario / report an error.

Mistake №3: making things nullable “just in case”, then being surprised by restrictions.
If a variable must exist by meaning (for example, the user’s name in a questionnaire), don’t declare it as String? “for the future”. Nullable types should be introduced where missing values are realistically possible and logically acceptable. Otherwise you make life harder for yourself, and the compiler honestly starts requiring handling where it shouldn’t be needed.

Mistake №4: confusing “parse error” with the value 0.
Sometimes a student thinks toIntOrNull() returns 0 on error. But 0 is a valid number, while null means “there is no number”. If you mix these meanings, the program will start treating invalid input as valid (for example, the age “kitty” will turn into 0), and you’ll get a logical bug that the compiler won’t catch anymore.

Mistake №5: writing a check but not changing behavior.
Sometimes you see code like: “if age == null, then… nothing”, and the program continues and runs into the same restrictions or the need for strange actions. A null check must lead to a decision: either we stop executing the current scenario (early return), or we define a separate behavior branch. If there is no decision, the check turns into a decorative “caution” sticker that does nothing.

1
Task
Kotlin SELF, level 11, lesson 2
Locked
Value adder
Value adder
1
Task
Kotlin SELF, level 11, lesson 2
Locked
Age-based fare
Age-based fare
1
Task
Kotlin SELF, level 11, lesson 2
Locked
Nickname normalization
Nickname normalization
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION