Step-by-step instructions for safely handling null-related issues when porting Java code to Kotlin.
Estimated time: about 15-20 minutes
TL;DR
- Types coming from Java are inferred in Kotlin as platform types (e.g.,
String!).- Adding
@Nullable/@NotNullannotations on Java code lets Kotlin infer the correct type.- Handle platform types defensively with
?.or?: defaultinstead of!!.- Migrate incrementally per class and verify with tests at each step.
What This Guide Solves#
Use this guide in the following situations:
- When you get
NullPointerExceptionwhile migrating Java code to Kotlin - When you can’t tell whether the return type of a Java method called from Kotlin is nullable
- When
!!is overused and null safety loses its meaning - When you need a safe way to use Java libraries from Kotlin
Before You Start#
| Item | Requirement |
|---|---|
| Kotlin | 1.9.x or later |
| Existing Java code | @Nullable/@NotNull annotations recommended |
| IntelliJ IDEA | Supports Java to Kotlin conversion |
Annotation dependency:
// build.gradle.kts
dependencies {
// JSR-305 annotations (@Nullable, @Nonnull)
implementation("com.google.code.findbugs:jsr305:3.0.2")
// Or the JetBrains annotations
implementation("org.jetbrains:annotations:24.0.0")
}Step 1: Understand Platform Types#
Kotlin treats Java return types as platform types. Platform types appear as String! and Kotlin cannot tell whether they’re nullable.
// Java code (UserService.java)
public class UserService {
public String getUserName(int id) { // May return null
if (id > 0) return "Alice";
return null;
}
}// When called from Kotlin
val service = UserService()
val name = service.getUserName(0) // Type: String! (platform type)
// Kotlin doesn't know whether it's null → runtime NPE risk
println(name.length) // NPE if id=0!Danger of platform types:
val name: String = service.getUserName(0) // Compiles, runtime NPE possible
val name: String? = service.getUserName(0) // Safer choiceStep 2: Add Annotations to Java Code#
Adding @Nullable/@NotNull to Java source lets Kotlin infer the exact type.
Using JetBrains annotations (recommended):
// UserService.java (after fix)
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.NotNull;
public class UserService {
@Nullable // May return null
public String getUserName(int id) {
if (id > 0) return "Alice";
return null;
}
@NotNull // Never returns null
public String getDefaultName() {
return "Guest";
}
public void processUser(@NotNull String name) { // Parameters too
// ...
}
}// Calling from Kotlin after annotations are added
val service = UserService()
val name = service.getUserName(0) // Type: String? (clearly nullable)
val default = service.getDefaultName() // Type: String (non-null)
// Compiler enforces null checks
println(name?.length ?: 0) // Safely handled
println(default.length) // No null check neededUsing JSR-305 annotations:
import javax.annotation.Nullable;
import javax.annotation.Nonnull;
public class OrderService {
@Nullable
public Order findOrder(@Nonnull String orderId) {
// orderId is non-null, return value is nullable
return repository.findById(orderId);
}
}Enable JSR-305 processing in Gradle:
// build.gradle.kts (recommended for Kotlin 2.0+)
kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
}
}The
kotlinOptions { freeCompilerArgs += ... }block from Kotlin 1.x is deprecated. From Kotlin 2.0, usecompilerOptions { freeCompilerArgs.addAll(...) }as shown above.
Step 3: Defensive Patterns for Platform Types#
When using external libraries you can’t annotate, handle nulls defensively.
Pattern 1: safe-call operator ?.
// External library return value (platform type)
val result = externalService.getData()
// Use ?. instead of !!
val length = result?.length ?: 0
val upper = result?.uppercase()Pattern 2: Elvis operator ?: for defaults
fun processUser(userId: String): String {
val name = userRepository.findName(userId) // String! (platform type)
return name ?: "No name" // Default if null
}Pattern 3: requireNotNull / checkNotNull
fun processOrder(orderId: String) {
val order = orderService.find(orderId) // Platform type
// Throws immediately if null (clearer error than an NPE)
requireNotNull(order) { "Order not found: $orderId" }
// After this line, order is smart-cast to non-null
println(order.status)
}Pattern 4: null-safe block with let
val user = userService.getUser(id) // Platform type
// Only execute when non-null
user?.let { u ->
println("Name: ${u.name}")
println("Email: ${u.email}")
}Patterns to avoid:
// Overusing !! — defeats null safety, runtime NPE risk
val name = service.getName()!! // Risky!
val length = service.getData()!!.length // Risky!
// Forcing a platform type to non-null
val name: String = service.getName() // No compiler warning, but riskyStep 4: Common NPE Patterns#
Pattern 1: nulls inside a collection
// List from Java (elements may be null)
val javaList: List<String?> = javaService.getNames() // List<String!>
// Filter out nulls safely
val safeNames: List<String> = javaList.filterNotNull()
// Or transform with mapNotNull
val lengths: List<Int> = javaList.mapNotNull { it?.length }Pattern 2: null from a Map lookup
val map: Map<String, String> = javaService.getMap()
// Wrong: Map.get() returns nullable
val value = map["key"]!! // NPE if key missing
// Right
val value = map["key"] ?: "default"
val value = map.getOrDefault("key", "default")Pattern 3: lateinit fields
// Bad: lateinit throws UninitializedPropertyAccessException if accessed before init
class UserControllerLateinit {
private lateinit var userService: UserService
// You can check whether it's initialized in advance
fun isInitialized() = ::userService.isInitialized
}
// Good: nullable + explicit check
class UserControllerNullable {
private var userService: UserService? = null
fun getService() = userService ?: throw IllegalStateException("Service not initialized")
}Pattern 4: nulls with generic types
// Whether T is nullable or non-null isn't clear from the Java side
fun <T> process(value: T): String {
// If T is String! and null is passed, value.toString() returns the "null" string
return value?.toString() ?: "null"
}Step 5: Incremental Migration#
Migrating the entire codebase at once is risky. Go class by class.
Recommended order:
1. Start with dependency-free utility classes
2. Domain models (convert to data class)
3. Service layer
4. Controllers / entry pointsStage 1: Add annotations to Java classes
// Add @Nullable/@NotNull to Java code first
@NotNull
public User findUser(@NotNull String id) {
User user = repository.find(id);
return Objects.requireNonNull(user, "User not found: " + id);
}Stage 2: Use IntelliJ’s Java-to-Kotlin conversion
- Open the Java file to convert
Code → Convert Java File to Kotlin File(orCtrl+Alt+Shift+K)- Review the auto-converted result
Stage 3: Conversion review checklist
// What to verify after auto-conversion:
// 1. Did platform types convert to appropriate nullable types?
var name: String? = null // OK
var name: String = "" // OK if Java side is non-null
// 2. Is `!!` used excessively?
val result = javaMethod()!! // Review → replace with `?:` or requireNotNull
// 3. Is `lateinit` used appropriately?
lateinit var service: Service // Only for DI targets
// 4. Are equals/hashCode generated correctly?
// Auto-generated for data classStage 4: Verify with tests
import kotlin.test.*
class UserServiceTest {
@Test
fun `handles null return case`() {
val service = UserService()
val name = service.getUserName(-1) // returns null
assertNull(name)
// Verify Kotlin null handling
val displayName = name ?: "Guest"
assertEquals("Guest", displayName)
}
@Test
fun `non-null return case`() {
val service = UserService()
val name = service.getUserName(1)
assertNotNull(name)
assertEquals("Alice", name)
}
}Step 6: Caveats in Spring Boot#
When using with Spring Boot, watch out for these:
// @RequestParam, @PathVariable are required=true by default
// Use String (not String?) in Kotlin to stay safer
@GetMapping("/users/{id}")
fun getUser(@PathVariable id: String): ResponseEntity<User> {
// id is non-null (guaranteed by Spring)
return ResponseEntity.ok(userService.findById(id))
}
// Optional parameters should be String?
@GetMapping("/users")
fun searchUsers(
@RequestParam(required = false) name: String?
): List<User> {
return if (name != null) {
userService.findByName(name)
} else {
userService.findAll()
}
}
// Prefer constructor injection over `lateinit var` for @Autowired
@Service
class UserService(
private val userRepository: UserRepository, // non-null, final
private val cacheService: CacheService
)Checklist#
Verify migration is complete:
- Are
@Nullable/@NotNullannotations added to Java code? - Is
-Xjsr305=strictenabled in Gradle? - Is
!!usage minimized? (UserequireNotNullonly where necessary) - Are platform types from external libraries handled with
?.and?:? - Are nullable elements in collections handled with
filterNotNull()? - Did you run tests after each class transition to confirm no NPE?
Related Documents#
- Null Safety — Kotlin null-handling concepts
- Coroutines Basics — null handling in suspend functions
- Gradle Kotlin DSL Tips — build configuration for migration