One of the reasons I switched to Kotlin is the null safety. After years of dealing with NullPointerException in Java, having the compiler check nullability for you feels like a relief. But if you work in a mixed Kotlin/Java codebase, as many of us do, you will discover that this safety has some holes that the JVM simply cannot enforce.
Platform types and delayed NullPointerException
When you call a Java method from Kotlin, the return type depends on whether the Java code has nullability annotations (@Nullable, @NotNull). Most Java code does not have them. In that case, Kotlin gives you what is called a platform type, written as String! in error messages. It basically means “I don’t know if this can be null or not.”
Here is a Java service without any annotations:
public class UserService {
public String getMiddleName(String id) {
return null; // most users don't have a middle name
}
}
And this is what happens when you call it from Kotlin:
val middleName = service.getMiddleName("alice")
// ... 50 lines of code later ...
println(middleName.length) // NullPointerException here!
The problem is that middleName has type String! (platform type). Kotlin does not check for null when you assign it. The NullPointerException happens much later, when you actually use the value. If the value gets passed around through several variables or methods, the NPE can surface very far from where the null was introduced. This makes the bug very difficult to trace back to the source.
I’ve spent quite some time debugging this kind of issue. The stack trace points to a line that looks perfectly fine, and you have to go backward through the code to find the Java method that returned null.
The simplest thing you can do is to always declare the type explicitly when you receive a value from Java:
// Option 1: declare as nullable — Kotlin forces you to handle null
val middleName: String? = service.getMiddleName("alice")
val length = middleName?.length ?: 0
// Option 2: declare as non-null — NPE happens immediately at this line
val middleName: String = service.getMiddleName("alice")
Both are better than letting Kotlin infer the platform type, because you know exactly where the problem is.
If your Java code uses JSR-305 annotations (like Spring’s @Nullable), you can also tell the Kotlin compiler to treat them strictly:
// build.gradle.kts
kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
}
}
Read-only collections that are actually mutable
Kotlin has two collection interfaces: List<T> (read-only) and MutableList<T>. In Kotlin, you cannot call add() on a List. This feels safe, but the safety is only at compile time. At runtime, Kotlin’s List is just java.util.List, and there is no enforcement.
This means that Java code can mutate what Kotlin considers a read-only list:
// Kotlin
class SecurityConfig {
val allowedOrigins: List<String> = mutableListOf("https://example.com")
}
// Java
public void addOrigin(SecurityConfig config) {
List<String> origins = config.getAllowedOrigins();
origins.add("https://evil.com"); // works at runtime!
}
The Kotlin compiler prevents you from calling add(), but it cannot prevent Java code from doing it. In a mixed codebase, your “read-only” configuration can be modified by any Java caller without you knowing.
Even within pure Kotlin, you can break it with a cast:
val list: List<Int> = listOf(1, 2, 3)
(list as MutableList<Int>)[0] = 999
If you need real immutability, for example in a security-sensitive configuration, you should wrap with Collections.unmodifiableList():
val allowedOrigins: List<String> = java.util.Collections.unmodifiableList(
listOf("https://example.com")
)
Now any attempt to mutate the list from Java will throw an UnsupportedOperationException.
internal is public in the bytecode
Kotlin’s internal visibility modifier is supposed to limit access to the same module. However, the JVM has no concept of module visibility at the bytecode level. So Kotlin compiles internal to public in bytecode, with a mangled name to discourage Java callers.
internal fun calculateDiscount(price: Double): Double {
return price * 0.1
}
In bytecode, this becomes something like public static double calculateDiscount$production_sources_for_module_main(double price). It is technically public. Java code in the same project can call it — the ugly name is the only thing protecting you.
There is no real fix for this. It is a limitation of the JVM. If you really need to hide something from Java code, you can put it in a separate Gradle module (module boundaries are enforced at compile time) or use private/protected instead.
Conclusion
Kotlin’s null safety and collection safety are genuinely useful — they catch a lot of bugs at compile time. But they are compile-time features, not runtime guarantees. At the boundary with Java, these protections can break down, and the tricky part is that they break down silently.
Whenever your Kotlin code touches Java, be explicit about types and be careful about mutability. Don’t rely on what the compiler tells you if the value comes from Java.