JavaRush/Courses/Kotlin SELF/Platform types in Kotlin: T! and contract T? vs T

Platform types in Kotlin: T! and contract T? vs T

Open

1. Introduction

If you program only in “pure” Kotlin, the compiler guards you quite strictly from NullPointerException: if the type is String, then null cannot slip in there. But Kotlin/JVM lives in the world of Java libraries, where historically null means “well, maybe, maybe not.” Therefore, as soon as you call a Java API, Kotlin sometimes has to honestly admit: “I don’t know whether this is nullable or not.”

And this is where the platform type appears—a thing that can let you catch an NPE even in Kotlin code. Not because Kotlin is “bad,” but because it communicates nicely with Java and cannot rewrite the entire ecosystem for you.

What is a platform type: T! in plain words

A platform type is a type that Kotlin gets from Java code when it cannot determine nullability. In IntelliJ IDEA you will often see it as String!, Int!, List<String!>!, etc. Important: T! is not a “new type” that you can write in code. It is a “margin note” from the compiler/IDE: “careful, the contract is unclear.”

Technically, this is related to so-called flexible types in Java interop, where Kotlin keeps a range of possible types until you “ground” the value yourself into a clear Kotlin contract. In the specification, this is described as the need to “blow up earlier” if you assign a Java result to an expected (non-null) type, so that the error shows up immediately rather than three functions and five minutes later.

Imagine someone brings you a box with a “Glass” sticker. It might contain glass, or it might contain cookies. You won’t know until you open it. A platform type is that box. And String? or String means “you opened it and checked.”

2. Why platform types are dangerous: the compiler is silent, and runtime crashes

The most unpleasant part about platform types is that the code can look “absolutely Kotlin-ish” yet crash at runtime. Moreover, the crash will not be a compilation error (which we are already used to as a “disciplinarian”), but a real runtime exception.

A classic example is System.getProperty(...). This is a Java API, and it can return null if the property does not exist.

fun main() {
    val mode = System.getProperty("my.app.mode") // IDE may highlight it as String!
    println(mode.length)                          // may crash with NPE
}

From Kotlin’s point of view, the variable mode here is a platform type: as if it is “both String and String? at the same time, depending on how you look at it.” And then you call .length, and if null actually came back, you’ll get an NPE.

In the Kotlin world, we are used to this not compiling, and that’s true for regular types (String / String?). But platform types are a “loophole” necessary for Java compatibility.

3. Strategy: lock down the contract at the boundary (T? or T)

The best way to fight platform types is not heroism (“come on, it’s definitely not null”), but discipline: as soon as a value comes from Java, you immediately decide what it means in Kotlin.

This “boundary” idea looks like this:

flowchart LR
    A[Java API: can return null] --> B[Platform type T!]
    B --> C{Contract choice}
    C -->|nullable| D[T? + ?. + ?: + checks]
    C -->|non-null| E[T + fail-fast check]

Contract selection table

What you do at the boundary When it is appropriate What you get
val x: T? = javaCall()
null is a normal situation (“there is no value”) Kotlin protects you again by forcing you to handle null
val x: T = javaCall()
null is a contract error; you want to crash immediately Fail-fast: if null still arrives, the program crashes quickly and explicitly

Notice an important detail: when there is an expected type (for example, you assign into val x: String), Kotlin can insert checks/asserts to “blow up earlier” if the value does not match expectations. This does not remove the problem, but it makes the crash earlier and more predictable.

5. Option 1: choose T? and handle null

When a value “may be absent,” that is a normal state, not an error. For example, the environment variable "APP_TOKEN" may be unset, and you just want to work in a “no token” mode.

fun main() {
    val raw: String? = System.getenv("APP_TOKEN") // lock the contract as nullable

    val token = raw
        ?.trim()
        ?.takeIf { it.isNotEmpty() }

    println(token ?: "<no token>") // <no token>
}

Here we immediately do three useful things: (1) tell the compiler “this is String?,” (2) normalize whitespace via trim(), (3) do not confuse “empty” with “no value” by dropping an empty string via takeIf { it.isNotEmpty() }.

Why is this good specifically in the platform types topic? Because we turn a “box” (String!) into a clear contract (String?), and then we use the same techniques we already learned for null-safety.

Nuance: null and "" are not the same

A very common “fix” looks like this: val x = javaCall() ?: "". This may be a design mistake. An empty string is a value. null is the absence of a value. If you automatically replace one with the other, you erase meaning.

Sometimes this is fine (for example, for cosmetic output in UI), but for configs, tokens, paths, and runtime modes it often turns into “why is the app running in a weird mode and staying silent.”

6. Option 2: choose T and do fail-fast

Sometimes the absence of a value is not “normal,” but “the program cannot work.” For example, you agree that the app starts only if "APP_DATA_DIR" is set. Then you want it to crash immediately and clearly, rather than keep running in a broken state.

There are several ways to do this, and the key is the idea itself: we explicitly assert the contract.

Way 1: require(...) + guard clause

We already know require (fail-fast style). Use it as a “gate” for the Java call:

fun main() {
    val dir: String? = System.getenv("APP_DATA_DIR")

    require(dir != null) { "APP_DATA_DIR is not set" }

    println("Data dir = $dir") // Data dir = ...
}

After require(dir != null), the variable dir at this point is smart-cast to String (as an immutable val), and you can continue without ?.. This approach fits the general philosophy “checked a precondition → the rest of the code is simpler.”

Way 2: a deliberate !!

!! means “I want the program to crash if there is null here.” In meaning, this is also fail-fast, but without an explanatory message (or with a less convenient one).

fun main() {
    val home = System.getenv("HOME")!!
    println("HOME=$home") // HOME=...
}

This option is acceptable only if you are truly confident in the environment contract. If you are not, require is better: its message is clearer, and during debugging you will understand faster what exactly went wrong.

7. Wiring it into a console app: config from Java API

So the topic does not remain “in a vacuum,” let’s add a small configuration layer to our learning console app (a hypothetical expense tracker with commands) that is sourced from the Java environment.

Our goal is very practical: to show that a platform type appears “where you didn’t expect it,” and how to tame it.

Read the run mode via System.getProperty

The property "my.app.mode" may be unset. Then the mode will be "DEFAULT".

fun readAppMode(): String {
    val raw: String? = System.getProperty("my.app.mode") // platform -> lock it down as String?
    val normalized = raw?.trim()?.uppercase()

    return normalized ?: "DEFAULT"
}

fun main() {
    val mode = readAppMode()
    println("mode = $mode") // mode = DEFAULT (if the property is missing)
}

Note: we do not “do the trick” right inside println; instead, we first lock down the nullable contract and only then perform normalization. This makes the code readable and, more importantly, makes debugging easier: you can put a breakpoint on raw and see what actually came back.

Read the user name and do not confuse null with an empty string

"user.name" is often set, but we won’t “take it on faith.”

fun readUserNameOrNull(): String? {
    val raw: String? = System.getProperty("user.name")
    return raw?.trim()?.takeIf { it.isNotEmpty() }
}

fun main() {
    val user = readUserNameOrNull()
    println("user = ${user ?: "<anonymous>"}") // user = <anonymous> (if empty/missing)
}

This clearly shows why takeIf is a helpful friend: it helps express the rule “the value is considered valid only if it is not empty.”

Add a run identifier via Java UUID

UUID generation is an example of a Java API where null issues usually do not exist, but it is useful to see that “Java methods look ordinary” and you do not need to be afraid of them. Plus it is convenient for logs.

import java.util.UUID

fun newRunId(): String {
    return UUID.randomUUID().toString()
}

fun main() {
    val runId = newRunId()
    println("runId = $runId") // runId = 550e8400-e29b-41d4-a716-446655440000 (example)
}

The key takeaway for platform types: Java APIs can be “safe” or “unsafe,” but the call looks the same. That’s why the habit “lock down the contract at the boundary” is not paranoia, but basic hygiene.

Why it’s better to ground the type into a variable first

It’s very tempting to write this:

val mode = System.getProperty("my.app.mode").trim().uppercase()

This is exactly how NPEs are born “in Kotlin, which is supposed to be safe!” The problem is not trim() and not uppercase(). The problem is that System.getProperty(...) can return null, and because of platform types the compiler does not always force you to handle this upfront.

The correct style is a bit longer, but you win predictability:

fun main() {
    val raw: String? = System.getProperty("my.app.mode")
    val mode = raw?.trim()?.uppercase() ?: "DEFAULT"

    println(mode) // DEFAULT
}

Yes, it’s 2 more lines. But it’s 20 fewer minutes of “why did it crash for the user, but not for me.”

8. Typical mistakes when working with platform types

Mistake #1: thinking Kotlin always protects you from NPE.
Inside pure Kotlin—almost always yes. But at the boundary with Java, platform types (T!) appear, and they can behave like “almost non-null” until the first real null at runtime. The cure is discipline: immediately ground the Java result into T? or T and do not leave it “floating.”

Mistake #2: assigning a Java result to a non-null type “because it’s more convenient.”
val x: String = System.getProperty("x") looks nice until you discover that the property is missing. Yes, the compiler tries to “blow up earlier” if the value does not match the contract, but for the user it is still a crash. Make the choice consciously: either String? + handling, or fail-fast with a clear require.

Mistake #3: “fixing” null with a default "" without understanding the meaning.
An empty string is often not equal to the absence of a value. If you do ?: "" for a path/token/mode, you can end up with a program that “doesn’t crash” but behaves incorrectly. If the value is truly optional, keep String?. If it is required, crash immediately with a clear message.

Mistake #4: writing long call chains directly on a Java result.
System.getenv("X").trim().lowercase() is short, but dangerous. A separate variable raw: String? and careful handling via ?. and ?: makes the code a bit longer, but much more stable and easier to read and debug.

Mistake #5: overusing !! as a “universal patch.”
!! turns a potential null into a guaranteed crash, often without context and with a less friendly message. If crashing is truly a design decision, express it via require/a check so that when it fails, it’s clear what exactly is missing and why it is critical.

1
Task
Kotlin SELF,  29 level,  1 lecture
Locked
Nickname Without Risk
Nickname Without Risk
1
Task
Kotlin SELF,  29 level,  1 lecture
Locked
Run mode
Run mode
1
Task
Kotlin SELF,  29 level,  1 lecture
Locked
Port is required
Port is required
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet