Kotlin Module 3 – Null safety
Platform types and Java nullability
See why values from Java reach Kotlin with platform types, where a null from Java throws, and how JSpecify annotations turn them into checked types.
What you will learn
- Explain platform types, shown as String!, and why the compiler lets every call on them through
- Predict where a null from Java fails when it reaches a Kotlin variable, a parameter or a list
- Use JSpecify annotations so that nulls from Java are checked while Kotlin code compiles
- Wrap an unannotated Java class in a Kotlin adapter whose public types say exactly what can be null
- Plan the move of a Java code base to JSpecify with @NullMarked packages and the compiler's warn mode
Before you start
What you’ll learn · Before you start
What you will learn
- Explain platform types, shown as String!, and why the compiler lets every call on them through
- Predict where a null from Java fails when it reaches a Kotlin variable, a parameter or a list
- Use JSpecify annotations so that nulls from Java are checked while Kotlin code compiles
- Wrap an unannotated Java class in a Kotlin adapter whose public types say exactly what can be null
- Plan the move of a Java code base to JSpecify with @NullMarked packages and the compiler's warn mode
Before you start
On this page
A Java method declared as String findCity(String pin) returns a city for three PIN codes and null for every other
one, and nothing in its signature says so. Kotlin cannot see inside Java code, so it neither trusts the result nor
distrusts it: it gives the value a platform type: The type Kotlin gives a value from Java code without nullability annotations, shown as String! in messages. It may or may not hold null, and the compiler lets the code treat it either way., written String!, and leaves the
decision to you.
You will see what each decision does when a null arrives, make Java code state its nullability with JSpecify annotations, and wrap a Java class you cannot change behind a Kotlin adapter with exact types.
A value from unannotated Java has a platform type
PinDirectory is a Java class without any nullability annotation. The session compiles it with javac, compiles a
Kotlin file against it with kotlinc, and runs the result:
from session import run
# A Java class without nullability annotations, compiled with javac, then used from Kotlin in four ways; and one
# deliberate type mismatch, so that the compiler's message shows the platform type String!.
run("""
cd plain
javac -d out PinDirectory.java
kotlinc -cp out Main.kt -include-runtime -d main.jar
java -cp main.jar:out MainKt
!kotlinc -cp out Show.kt -d show
""") Output
$ cd plain
$ javac -d out PinDirectory.java
$ kotlinc -cp out Main.kt -include-runtime -d main.jar
$ java -cp main.jar:out MainKt
110001: New Delhi, 9 letters
999999 as String?: no city
inferred: java.lang.NullPointerException: Cannot invoke "String.length()" because "city" is null
declared String: java.lang.NullPointerException: findCity(...) must not be null
$ kotlinc -cp out Show.kt -d show
Show.kt:3:21: error: initializer type mismatch: expected 'Int', actual 'String!'.
val digits: Int = PinDirectory().findCity("110001")
^
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 platform_session.py
delhi.length compiled without a ?., although findCity can return null: the compiler allows every call on
platform types and reports nothing. The last command is a deliberate mistake whose message names the type it gave the
value, String!, meaning “String or String?, the compiler does not know which”. You cannot write String! yourself;
it appears only in messages and in the editor’s hints.
Platform types leave the decision to you, and the decision is the type your code gives the value. Step through the PIN code 999999, which the directory does not know:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The call. Main.kt calls findCity("999999"). The Java method looks the PIN code up in its map, finds nothing and returns null. Its signature says only String, so Kotlin gives the result the platform type String!.
- Java returns null. The null comes back to Kotlin. Nothing has failed yet: the type that the Kotlin code gives the value decides where, or whether, it fails.
- Declared String?. In the version declared String?, nothing is checked and nothing fails: the compiler made the code handle null with ?. and ?:, and it printed no city.
- Inferred, then used. In the inferred version, city keeps the platform type and the code calls city.length. The JVM finds null there and throws: Cannot invoke "String.length()" because "city" is null.
- Declared String. In the version declared String, the compiler added a null check right after the call. The check sees null and throws at once: findCity(...) must not be null, which names the Java call.
- The difference. Both exceptions are NullPointerExceptions, but the declared type moved the failure to the boundary with Java and named the call, while the inferred one failed later, wherever the value happened to be used.
A variable declared String? is always safe: the compiler then asks for ?. or ?:, and the program printed “no
city”. A variable declared String is checked by Kotlin when it is assigned, so a null fails there, with a message that
names the Java call. A variable with an inferred type keeps the platform type and fails later, wherever it is used. The
coding conventions turn this into a rule for public code: a function that returns a value of a platform type declares
its Kotlin type, so that the decision is made once, where the value enters Kotlin.
Key idea
Give every value from unannotated Java its Kotlin type where it enters Kotlin: String? when the Java code can
return null, String only when it cannot, and never a platform type that spreads through inferred types.
Checkpoint
Check: Platform types, and where a Java null fails
Nullability annotations turn platform types into real types
Java nullability can be written into the Java code itself, as
nullability annotation: An annotation in Java code, such as JSpecify's @Nullable, that says whether a type may hold null. Kotlin reads it and sees the Java type as a real nullable or non-null type. that Kotlin reads. An annotated type reaches Kotlin as
a real String? or String, and the compiler checks every use. JSpecify is a common set of such annotations for JVM
languages: inside a class or package marked @NullMarked: A JSpecify annotation on a class, a package or a module: inside it, every type that has no @Nullable is non-null., a type without @Nullable is
non-null. Here is the
same directory with two annotations, used from the code that compiled before:
from session import library, run
# The directory annotated with JSpecify 1.0.0, whose jar is checked against the SHA-256 published on Maven Central.
library("org.jspecify:jspecify:1.0.0", "marked/lib")
run("""
cd marked
shasum -a 256 lib/jspecify-1.0.0.jar
javac -cp lib/jspecify-1.0.0.jar -d out PinDirectory.java
!kotlinc -cp out:lib/jspecify-1.0.0.jar Broken.kt -d broken
kotlinc -cp out:lib/jspecify-1.0.0.jar Fixed.kt -include-runtime -d fixed.jar
java -cp fixed.jar:out:lib/jspecify-1.0.0.jar FixedKt
""") Output
$ cd marked
$ shasum -a 256 lib/jspecify-1.0.0.jar
1fad6e6be7557781e4d33729d49ae1cdc8fdda6fe477bb0cc68ce351eafdfbab lib/jspecify-1.0.0.jar
$ javac -cp lib/jspecify-1.0.0.jar -d out PinDirectory.java
$ kotlinc -cp out:lib/jspecify-1.0.0.jar Broken.kt -d broken
Broken.kt:5:18: error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type 'String?'.
println(delhi.length)
^
Broken.kt:6:22: error: initializer type mismatch: expected 'String', actual 'String?'.
val city: String = directory.findCity("999999")
^
Broken.kt:8:32: error: null cannot be a value of a non-null type 'String'.
println(directory.findCity(null))
^^^^
[exit status 1]
$ kotlinc -cp out:lib/jspecify-1.0.0.jar Fixed.kt -include-runtime -d fixed.jar
$ java -cp fixed.jar:out:lib/jspecify-1.0.0.jar FixedKt
110001: 9 letters
999999: unknown PIN code
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 annotated_session.py
All three lines of Broken.kt would compile against the plain class, and all three are refused now, each with the
message of an ordinary Kotlin type: findCity gives a String?, so delhi.length and the assignment to a String are refused, and its parameter
is a non-null String, so findCity(null) is refused too. The fixed version handles null where the value arrives.
The first command printed the SHA-256 of the JSpecify 1.0.0 jar, so you can check that your copy is the same file.
Kotlin reads several families of nullability annotations, and Java nullability reached more code in stages:
- Annotations Kotlin reads
One annotation per library family no platform type
Kotlin reads the nullability annotations of several families, among them JetBrains' org.jetbrains.annotations, Android's, JSR-305's javax.annotation, Eclipse's, Lombok's and RxJava's. An annotated Java type reaches Kotlin as a real type, not a platform type.
- Android 9 (API 28)
The Android SDK annotated warnings first
The Android 9 SDK added nullability annotations to some commonly used APIs. For a while, Kotlin reported mismatches with these new annotations as warnings, not errors, so that code which compiled before kept compiling.
- JSpecify 1.0.0
A common set of annotations one standard
JSpecify, made by several organisations together, gives JVM languages one set of nullness annotations with a precise meaning: @Nullable, @NonNull, @NullMarked and @NullUnmarked.
- Kotlin 2.1.0
JSpecify mismatches become errors strict by default
From Kotlin 2.1.0 on, the compiler reports nullability mismatches with JSpecify annotations as errors by default, where earlier versions reported them as warnings.
- Spring Framework 7
A large framework moves over JSpecify in use
Spring Framework 7's code is annotated with JSpecify, usually with @NullMarked on each package, and its own older nullability annotations are deprecated. Kotlin code sees its APIs with real nullable and non-null types.
The timeline has 5 events, from Annotations Kotlin reads to Spring Framework 7.
Since Kotlin 2.1.0, a JSpecify mismatch is an error by default, which is why Broken.kt failed instead of compiling
with warnings. A Java library you call may already carry these annotations: the Spring Boot documentation says that
most Spring projects provide null safety through JSpecify, and Android’s interop guide asks every public Java API to
annotate the nullability of its parameters, returns and fields.
Checkpoint
Check: Nullability annotations and JSpecify
Where Kotlin checks values at the Java boundary, measured Pro: opens with a Pro, Premium or Ultimate pass
Kotlin cannot stop Java from passing or returning null, so the compiler writes checks where values cross from one
language to the other. This part finds those checks in the bytecode, measures what each one throws, and shows the case
that gets past all of them.
In this part:
- The checks the compiler writes, found with javap
- Two compiler options leave these checks out
- A null inside a list gets past every check
- The JVM’s messages and Kotlin’s
1 runnable example (Python) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Moving a Java code base to JSpecify, step by step Pro: opens with a Pro, Premium or Ultimate pass
Annotating a large Java code base in one change is rarely possible, and every annotation changes what the Kotlin code that calls it may compile. This part marks one package, leaves a sub-package for later, and uses the compiler’s warn mode to move without a broken build.
In this part:
- @NullMarked on a whole package
- @Nullable on the items of a list
- The warn mode keeps the build green while callers move
- Annotate, wrap or decide at each call
1 runnable example (Python) · about 7 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise: an adapter with exact types
LegacyDirectory is an old Java class that you cannot change, and it returns null in two ways without saying so.
CityLookup.kt wraps it in a Kotlin class, and as given, three of its four checks fail:
from session import run
# The practice: an old Java class, and the Kotlin adapter whose checks fail as it is given.
run("""
cd practice
javac -d out LegacyDirectory.java
kotlinc -cp out CityLookup.kt -include-runtime -d lookup.jar
!java -cp lookup.jar:out CityLookupKt
""") Output
$ cd practice
$ javac -d out LegacyDirectory.java
$ kotlinc -cp out CityLookup.kt -include-runtime -d lookup.jar
$ java -cp lookup.jar:out CityLookupKt
ok city("110001") = New Delhi, want New Delhi
FAIL city("999999") = NullPointerException, want null
FAIL cities("Goa") = NullPointerException, want []
FAIL cities("Kerala") = [Kochi, null, Thiruvananthapuram], want [Kochi, Thiruvananthapuram]
1 of 4 checks pass
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py
Copy the two files, give CityLookup exact types, and run the four commands of the session until all four checks
pass. First hint: city should say in its return type that the city may be unknown, and a platform list can be
cleaned with orEmpty() and filterNotNull() before it leaves the adapter.
Then test what you know of platform types and of the annotations that replace them. The questions start with the idea your answers show you know least:
Practice that adapts to you
Practise this lesson
5 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
-
Fresher to mid · Warm-up
What is a platform type in Kotlin, and how should you handle a value that has one?
A platform type is the type Kotlin gives a value from Java code that has no nullability annotations. The compiler shows it as
String!, meaningStringorString?, it cannot tell which, and it lets your code treat the value either way without a check. Handle it by deciding where the value enters Kotlin: declareString?when the Java method can returnnull, and the compiler makes you handle it with?.or?:; declareStringonly when it never can, and Kotlin checks the value at the assignment, failing with a message such asfindCity(...) must not be null. Do not let the platform type spread through inferred types, and where you own the Java code, annotate it so that Kotlin sees real types. -
Mid to senior
Java code in your service calls a Kotlin function fun register(name: String) and sometimes passes null. Where exactly does it fail, what does the error tell you, and how would you design the Kotlin side for its Java callers?
-
Fresher to mid
A Java method returns a List<String> that sometimes holds null items. Kotlin code calls names.map { it.length } on the result and crashes. Where does it fail, why did the compiler not catch it, and what should the Kotlin side do?
-
Senior to staff
Your Kotlin services depend on a large Java library, owned by another team, with no nullability annotations, and its values cause most of your NullPointerExceptions. Would you annotate it, wrap it, or declare types at every call? Plan the change.
Key takeaways
- A value from unannotated Java has a platform type, shown as
String!in messages, and the compiler allows every call on it, so anullfails while the program runs. - Give a Java value its Kotlin type where it arrives:
String?makes the compiler ask for?.or?:, andStringfails at the assignment with a message that names the Java call. - With nullability annotations such as JSpecify’s
@NullMarkedand@Nullable, Java types reach Kotlin as realString?andString, and since Kotlin 2.1.0 a JSpecify mismatch is a compile error.
Terms in this lesson
- @NullMarked
- A JSpecify annotation on a class, a package or a module: inside it, every type that has no @Nullable is non-null.
- nullability annotation
- An annotation in Java code, such as JSpecify's @Nullable, that says whether a type may hold null. Kotlin reads it and sees the Java type as a real nullable or non-null type.
- platform type
- The type Kotlin gives a value from Java code without nullability annotations, shown as String! in messages. It may or may not hold null, and the compiler lets the code treat it either way.
References
- Calling Java from Kotlin: null safety and platform types (JetBrains (kotlinlang.org))
- Nullability in Java and Kotlin (JetBrains (kotlinlang.org))
- Null safety: the causes of a NullPointerException in Kotlin (JetBrains (kotlinlang.org))
- What's new in Kotlin 2.1.0: JSpecify nullability mismatches become errors (JetBrains (kotlinlang.org))
- Coding conventions: platform types (JetBrains (kotlinlang.org))
- JSpecify: start here (JSpecify)
- JSpecify user guide (JSpecify)
- Spring Framework: null safety (Spring (spring.io))
- Spring Boot: Kotlin support, null safety (Spring (spring.io))
- Kotlin-Java interop guide: nullability annotations (Google (Android Developers))
- Android Pie SDK is now more Kotlin-friendly (Google (Android Developers Blog))
- JEP 358: Helpful NullPointerExceptions (OpenJDK)
Report a problem with this lesson
Kept only in this browser. Your Learn progress