Stop Runtime NPEs: Enforcing Null Safety at the Kotlin-Java Boundary with JSR-305 Strict Mode
Kotlin treats unannotated Java types as platform types, deferring null failures to runtime. Annotate Java APIs and enable -Xjsr305=strict to move enforcement to compile time.
04 Apr 2026, 05:46 UTC

The hidden cost of platform types at the Java/Kotlin boundary
When Kotlin calls a Java method whose signatures lack nullability annotations, the Kotlin compiler does not guess. It labels the type as a platform type, displayed as T!. A platform type can be used as either nullable or non-nullable, so the compiler permits operations that will throw a NullPointerException at runtime. In mixed modules this is the most common source of latent NPEs: the Java API never promised non-null, and Kotlin never enforced it.
The practical fix is to make the contract explicit. Annotate Java parameters and return values, then tell the Kotlin compiler to treat JSR-305 annotations as hard errors rather than warnings. The result is that boundary violations fail the build instead of surfacing in production.
Desired outcome
Every Java type consumed by Kotlin should carry an explicit nullability contract. Kotlin call sites should see ordinary non-null or nullable types—never flexible platform types—so the compiler rejects unsafe dereferences and illegal null arguments before the code runs.
Prerequisites
- A Gradle-based project containing both Java and Kotlin source sets.
- Kotlin compiler access to
freeCompilerArgs(standard in the Kotlin Gradle plugin). - Ability to add annotations to Java sources, or a plan to guard third-party APIs on the Kotlin side.
- Basic familiarity with Kotlin nullable types (
?) and Gradle task names.
Step 1: Annotate Java APIs with explicit nullability
Use JetBrains annotations (org.jetbrains.annotations.NotNull, @Nullable) or JSR-305 annotations (javax.annotation.Nonnull, @Nullable). JetBrains annotations are recognized directly by the Kotlin compiler; JSR-305 annotations are also supported but only become compile-time errors when strict mode is active.
// src/main/java/com/example/UserService.java
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
public class UserService {
public @NotNull User findById(@NotNull String id) {
// implementation
}
public @Nullable User findByEmail(String email) {
// implementation
}
}
In Kotlin, findById now returns a non-null User and requires a non-null String. findByEmail returns User?, forcing callers to handle absence. If your Java code uses generics, ensure the annotation library version supports TYPE_USE targets; older artifacts may not annotate type arguments such as List<@NotNull String>.
Step 2: Enable strict JSR-305 in the Kotlin compiler
By default the Kotlin compiler reports JSR-305 violations as warnings. The -Xjsr305=strict flag promotes them to errors. Apply it in Gradle so every Kotlin compilation task inherits the policy:
// build.gradle.kts
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
freeCompilerArgs.add("-Xjsr305=strict")
}
}
Run the compile task from the module root or repository root:
./gradlew :your-module:compileKotlin
Replace :your-module with the actual Gradle project path. Expected result: if Kotlin code passes null to a @NotNull Java parameter, or dereferences a @Nullable return without a null check, the task fails with a diagnostic pointing to the exact call site. If the build succeeds, all annotated contracts are currently satisfied.
Risk: Enabling strict mode across a large legacy module can produce hundreds of errors at once. Roll it out per source set or per module, starting with new code, rather than switching it on globally in one commit.
Step 3: Guard Java code you cannot annotate
Third-party libraries and legacy modules may ship without nullability metadata. Platform types from those dependencies remain flexible. Insert explicit Kotlin-side guards at the boundary and document the invariant:
// Kotlin call site
val user = requireNotNull(legacyUserRepository.find(id)) {
"User must exist for id=$id"
}
requireNotNull throws IllegalArgumentException when the value is null; checkNotNull throws IllegalStateException. Choose based on whether a missing value indicates bad input or broken internal state. Record the expectation in the module’s contribution guide so future callers repeat the guard instead of reintroducing platform types.
Expected checks and diagnostics
- Compile enforcement: Run
./gradlew :your-module:compileKotlinafter enabling-Xjsr305=strict. The build must fail on any Kotlin violation of an annotated Java contract. - Negative test: Add a Kotlin unit test that deliberately passes
nullto an annotated Java method. Confirm the compilation fails, proving the flag is active. Remove the test after verification. - Configuration audit: Inspect the
freeCompilerArgsfor each Kotlin source set. A missing flag in one source set means that source set still runs in default warning mode. - Runtime assertions: Enable assertions (
-ea) in integration tests to surface latentNullPointerExceptions from unannotated Java dependencies that strict mode cannot see.
Recovery options when strict mode breaks the build
If a legacy module suddenly fails with many JSR-305 errors, you have three practical recovery paths:
- Downgrade temporarily: Change the flag to
-Xjsr305=warnto restore the previous behavior while you annotate incrementally. - Scope the flag: Apply
strictonly to new source sets or modules, leaving legacy code inwarnuntil it is migrated. - Annotate first: Add missing
@NotNull/@Nullableannotations to the Java sources that generate the most errors, then re-enable strict mode.
Because these changes alter compiler configuration and possibly source annotations, keep them in version control and verify with a clean build before merging.
Limitations and practical verification
Strict JSR-305 mode closes many gaps, but it does not eliminate every interop hazard:
- Published metadata: Platform types are erased in Kotlin metadata. If a Java library is published without nullability annotations, downstream Kotlin consumers still see flexible types even if your internal build is strict.
- Annotation processors: Tools such as Lombok, Jackson, and MapStruct may generate bytecode that bypasses source-level annotations. Verify generated classes or configure each framework’s own null-handling settings.
- Experimental contracts: Kotlin’s
contractblocks can express nullability implications, but they remain experimental. Use them only for internal helpers and annotate with@OptIn(ExperimentalContracts::class).
To check the result, perform a clean compile of the affected module and review the diagnostics report. Then intentionally violate one annotated contract in a scratch branch; the compile task should fail. Revert the violation, confirm the build passes, and inspect the Gradle task graph to ensure the strict flag is present for every Kotlin source set you intended to protect.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.