"The Billion-Dollar Fix" — Null Safety and the Type System
A NullPointerException from a Java supplier SDK crashes Kabir's Kotlin code. We cover nullable types and the safe-call operators, what they compile to, smart casts and their limits, platform types and JSpecify, lateinit vs lazy, Any/Unit/Nothing, and == vs ===.
Story Opening
The staging alert fired at 16:40 on Thursday: product-catalog-service had stopped importing the supplier feed. The stack trace was short:
java.lang.NullPointerException: getDescription(...) must not be null at com.shelfwise.catalog.importer.TitlesKt.shelfTitle(Titles.kt:6)Line 6 was Kabir’s, and it was one line long:
// Fragment of story/Incident.kt// The line from the staging crash: no '?', no '!!', and still a NullPointerException.fun shelfTitle(item: SupplierItem): String = item.description.uppercase()Kabir had spent a week enjoying a compiler that refused to let him put null into a String, and somewhere along the way he had stopped thinking about nulls at all.
SupplierItem came from the supplier’s Java SDK. For items the supplier hadn’t catalogued yet, getDescription() returned null. The SDK’s documentation didn’t say so, because the SDK had no documentation.
“Null safety is a property of Kotlin’s type system,” Lena said, reading over his shoulder. “That method’s return type isn’t from Kotlin’s type system. So what type did you think it had?”
Java → Kotlin: The Quick Map
| Java | Kotlin | Note |
|---|---|---|
String s (may be null, who knows) | String / String? | Nullability is part of the type |
if (p != null) p.getCode() | p?.code | Safe call: null if p is null |
x != null ? x : fallback | x ?: fallback | Elvis operator |
Objects.requireNonNull(x) | x!! or x ?: error("why") | Prefer the message |
(String) o in a try/catch | o as? String | null instead of ClassCastException |
Optional<T> | T? | No wrapper object; works for fields and parameters |
instanceof + cast | is + smart cast | No explicit cast after the check |
Object | Any / Any? | Any? is the true top type |
void | Unit | A type with one value |
| (no equivalent) | Nothing | The type of throw and of functions that never return |
a.equals(b) | a == b | Null-safe |
a == b (references) | a === b | Identity |
Arrays.equals(a, b) | a.contentEquals(b) | Arrays don’t override equals |
@Nullable from JSpecify | Read as T? | Strict by default |
Conceptual Deep-Dive
Null is a value, so it belongs in the type
In Java every reference type has an extra, invisible member: null. String means “a string, or nothing”, and the compiler never asks which. Tony Hoare, who added null references to ALGOL W in 1965, later called them his “billion-dollar mistake”. Kotlin’s fix is to make the invisible member visible and optional:
Stringholds a string. Always. The compiler enforces it.String?holds a string ornull. You must handle both before you call anything on it.
String is a subtype of String?: you can pass a String anywhere a String? is expected, but not the reverse. That one subtyping rule produces the whole hierarchy:
Two types at the edges are worth learning now. Any? is the true top type, the honest version of Java’s Object. Nothing is the bottom type: a subtype of every type, with no values. An expression of type Nothing never completes normally, because it throws. That sounds academic until you see that it is what lets val sku = raw ?: error("missing") have type String rather than String?.
Why not Optional?
Java’s answer to null is a wrapper: Optional<T> is an object you allocate, unwrap, and are told not to use for fields, parameters or collections. Kotlin’s T? for a reference type is not an object at all: it is the same JVM reference, with the compiler tracking whether it might be null. (Nullable primitives are the exception: Int? is a boxed Integer, as Part 1 showed.) It works everywhere a type works and needs no map/orElse vocabulary, because the operators cover it.
You will still meet Optional in Java APIs. Convert it at the boundary and carry on with T?:
import java.util.Optionalimport kotlin.jvm.optionals.getOrNull
// A Java API that returns Optional, as many repositories and clients do.fun findPromotionCode(sku: String): Optional<String> = if (sku == "SUP-7781") Optional.of("DIWALI10") else Optional.empty()
fun main() { // getOrNull() turns Optional<T> into T?, and from there the null operators take over. val code: String? = findPromotionCode("SUP-7782").getOrNull() println(code ?: "no promotion") // -> no promotion println(findPromotionCode("SUP-7781").getOrNull()?.lowercase()) // -> diwali10}getOrNull() lives in kotlin.jvm.optionals. Spring Data offers the same thing for repositories as findByIdOrNull (Part 13).
Where the guarantee stops: the Java boundary
A guarantee needs information. When Kotlin calls a Java method, it often has none: an unannotated String getDescription() might return null or might not. Kotlin’s designers had two bad options:
- Treat every Java reference as nullable. Then every call into the JDK, Spring or Jackson would need
?.or!!, and the noise would bury the real risks. - Treat every Java reference as non-null. That would be a lie, and the compiler would build on it.
Kotlin does neither. It gives unannotated Java types a third kind of type: a platform type, written String! in IDE hints and error messages. You can’t write String! yourself. It means “nullability unknown, you decide”. The compiler lets you use it as String or String? and checks neither choice. Kabir chose String without realising he’d chosen anything.
The fix has two parts, and this part covers both: decide explicitly at the boundary, and get the Java side annotated so there is nothing to decide.
Technical Explanation
The operators
class Promotion(val code: String, val percentOff: Int)
class Product(val name: String, val promotion: Promotion?, val brand: String?)
fun main() { val ghee = Product("Desi Ghee 1L", Promotion("DIWALI10", 10), brand = null) val rice = Product("Sona Masoori 5kg", promotion = null, brand = "Shelfwise Basics")
// val code: String = ghee.promotion.code // error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type 'Promotion?'.
// ?. : call if not null, otherwise the whole expression is null. println(ghee.promotion?.code) // -> DIWALI10 println(rice.promotion?.code) // -> null
// ?: (Elvis): the fallback when the left side is null. println(rice.promotion?.percentOff ?: 0) // -> 0 println(ghee.brand ?: "Unbranded") // -> Unbranded
// ?.let { } : run a block only for non-null values; 'it' is smart-cast to the non-null type. rice.brand?.let { println("Brand page: /brands/${it.lowercase().replace(' ', '-')}") } // -> Brand page: /brands/shelfwise-basics ghee.brand?.let { println("never printed") }
// as? : a cast that yields null instead of throwing ClassCastException. val scanned: Any = 8_901_234_567_890L val barcode: String? = scanned as? String println(barcode ?: "not a string barcode") // -> not a string barcode
// !! : "I know better". Throws NullPointerException when you don't. try { println(rice.promotion!!.code) } catch (e: NullPointerException) { println("NPE, message: ${e.message}") // -> NPE, message: null }}A few rules a Java developer needs early:
- A safe-call chain short-circuits. In
a?.b?.c, ifais null the result isnullandbis never touched. The type of the whole chain is nullable. - The right side of
?:is only evaluated when needed, so it can be expensive, or it can bereturn,continueorthrow. !!is an assertion, not a conversion. It throwsNullPointerExceptionwith anullmessage, so a stack trace from!!tells you the line but not what was missing.x ?: error("price missing for $sku")costs a few more characters and saves an investigation.
What String? compiles to
For a reference type, nothing. A nullable type is an ordinary JVM reference plus an annotation for tools. This is normaliseSku from the hands-on below:
// Fragment of importer/SupplierImporter.kt// Step 1: validation returns null for "no valid SKU" instead of throwing.fun normaliseSku(raw: String?): String? = raw?.trim()?.uppercase()?.takeIf { it.matches(Regex("SUP-\\d{4}")) }@org.jetbrains.annotations.Nullablepublic static final String normaliseSku(@org.jetbrains.annotations.Nullable String raw); Code: 0: aload_0 1: dup 2: ifnull 71 // raw == null -> return null 8: invokestatic StringsKt.trim(CharSequence) 14: dup 15: ifnull 71 // trimmed == null -> return null 21: invokevirtual String.toUpperCase(Locale) 27: invokestatic Intrinsics.checkNotNullExpressionValue // see "The checks Kotlin inserts" 31: ifnull 71 ... 56: invokevirtual Regex.matches(CharSequence) 60: ifeq 67 // takeIf: no match -> null 63: aload_1 64: goto 73 67: aconst_null 71: pop 72: aconst_null 73: areturnEach ?. is a dup/ifnull jump to a shared exit that returns null, which is the code you would write by hand in Java. The @Nullable annotations are class-file-only (RuntimeInvisibleAnnotations): invisible to reflection, but read by IntelliJ and other static-analysis tools. takeIf disappeared entirely because it is an inline function (Part 5).
The checks Kotlin inserts for you
Kotlin can’t stop Java from passing null, but it can refuse to let null travel. The compiler inserts two kinds of check.
On entry to every non-private function with non-null parameters. Java code that passes null fails at the door:
// A non-null parameter is a promise Java callers can break. Kotlin checks it on entry.fun normalise(sku: String): String = sku.trim().uppercase()// LegacyCaller.java, in the same moduleSystem.out.println(SkuRulesKt.normalise(" sup-7781 ")); // -> SUP-7781try { SkuRulesKt.normalise(null);} catch (NullPointerException e) { System.out.println(e.getMessage()); // -> Parameter specified as non-null is null: method com.shelfwise.part02.platform.SkuRulesKt.normalise, parameter sku}Where a platform value meets a non-null Kotlin type. That is the check that caught Kabir. The bytecode for shelfTitle shows it:
public static final String shelfTitle(SupplierItem item); Code: 0: aload_0 1: ldc "item" 3: invokestatic Intrinsics.checkNotNullParameter // entry check 7: invokevirtual SupplierItem.getDescription() 10: dup 11: ldc "getDescription(...)" 13: invokestatic Intrinsics.checkNotNullExpressionValue // platform value used as non-null 19: invokevirtual String.toUpperCase(Locale) 23: ldc "toUpperCase(...)" 25: invokestatic Intrinsics.checkNotNullExpressionValue // even the JDK's return value is checkeduppercase() is a Kotlin extension function on a non-null String, so passing it a platform value made the compiler insert checkNotNullExpressionValue. That check is why the message named getDescription(...) instead of failing somewhere deeper. The second check guards the JDK’s own toUpperCase result, which is also a platform value as far as Kotlin knows. These checks cost a static call each, and -Xno-param-assertions and -Xno-call-assertions exist to remove them. Don’t: they are what turns a corrupted object graph into a clear failure at the boundary.
Smart casts, and when the compiler refuses
After if (x != null) or if (x is String), Kotlin treats x as the narrower type inside the branch. You saw when do this in Part 1. Kotlin smart-casts only when it can prove the value can’t change between the check and the use:
open class Shelf(open val label: String?)
class Bay(var note: String?)
fun labelLength(shelf: Shelf): Int { // if (shelf.label != null) return shelf.label.length // error: smart cast to 'String' is impossible, because 'label' is a property that has an open or custom getter. // A subclass could override 'label' with a getter that returns null on the second call.
// Fix: read it once into a local val. Locals can't change behind your back. val label = shelf.label return if (label != null) label.length else 0}
fun noteLength(bay: Bay): Int { // if (bay.note != null) return bay.note.length // error: smart cast to 'String' is impossible, because 'note' is a mutable property that could be mutated concurrently.
// Fix: let the safe-call chain carry the null, or copy to a local as above. return bay.note?.length ?: 0}
fun describe(value: Any?): String { // Smart casts flow through conditions: after the null check, 'value' is Any; after 'is', String. if (value == null) return "nothing" if (value !is String) return "a ${value::class.simpleName}" return "text '${value.trim()}'" // smart cast: value is String here}
fun main() { println(labelLength(Shelf("Spices"))) // -> 6 println(noteLength(Bay(null))) // -> 0 println(describe(null)) // -> nothing println(describe(42)) // -> a Int println(describe(" basmati ")) // -> text 'basmati'}The rule behind both errors: a property is a method call (Part 3 shows the getter). Reading it twice may give two answers: an open property can be overridden, a custom getter can compute anything, and a var can be changed by another thread between your check and your use. Smart casts work on:
- local
vals, and localvars not captured by a lambda that modifies them; valproperties with no custom getter that are notopen, provided they are private or internal, or the check happens in the same module that declares them.
The fix is always the same: copy the property into a local val, then check the local.
Platform types and JSpecify
All three ways to receive a platform value, side by side:
fun main() { val item = SupplierItem("SUP-7781", null, listOf("gluten"))
// item.description is a platform type, shown by the IDE as String!. // Kotlin lets you treat it as either String or String?, and checks nothing at compile time.
// Option 1, the safe one: declare it nullable at the boundary and handle the null in Kotlin. val description: String? = item.description println(description ?: "(no description)") // -> (no description)
// Option 2: declare it non-null. Kotlin inserts a check right here, so a null fails // at the boundary instead of three calls later. try { val strict: String = item.description println(strict) } catch (e: NullPointerException) { println(e.message) // -> getDescription(...) must not be null }
// Option 3, the trap: let inference choose. 'inferred' keeps the platform type String!, // so nothing is checked here and the null travels on until something dereferences it. val inferred = item.description println("title: $inferred") // -> title: null try { println(inferred.length) } catch (e: NullPointerException) { println(e.message) // -> Cannot invoke "String.length()" because "inferred" is null }
// With JSpecify annotations (@NullMarked package, @Nullable getter) there is no platform type: // Kotlin sees String? and the compiler forces you to deal with it. val v2 = SupplierItemV2("SUP-7781", null) // val title: String = v2.description // error: initializer type mismatch: expected 'String', actual 'String?'. println(v2.description?.uppercase() ?: v2.code) // -> SUP-7781 println(v2.code.length) // -> 8}Option 3 is the one to fear, because it is how a Java developer used to var writes it. val inferred = item.description doesn’t pick String or String?. It keeps String!, and no check is inserted. The null can be logged, stored in a map or passed through three Java methods before something finally dereferences it, and by then the stack trace points far from the cause. String.length is a JVM method call, so the failure is a plain JVM NPE. Kabir’s uppercase() is a Kotlin extension, which is why his crash at least happened at the boundary.
The lasting fix is on the Java side. The supplier’s version 2 SDK marks its package @NullMarked and annotates the exceptions:
@NullMarkedpackage com.supplier.sdk.v2;
// SupplierItemV2.javapublic @Nullable String getDescription() { return description; }public String getCode() { return code; } // non-null: the package defaultKotlin reads JSpecify annotations as real types, String? and String, and since Kotlin 2.1 reports mismatches as errors by default (tunable with -Xjspecify-annotations=strict|warn|ignore). It also understands the JetBrains, JSR-305, Android, Eclipse and Lombok nullability annotations. This matters for Act III: Spring Framework 7, and so Spring Boot 4, annotates its whole API with JSpecify, so calls into Spring from Kotlin no longer return platform types.
Tip — You own Java code that Kotlin calls? Add
org.jspecify:jspecifyand put@NullMarkedon its packages. It is the cheapest way to make the boundary type-safe, and your Java callers benefit too, through IntelliJ’s warnings.
lateinit, nullable or lazy?
Java fields start out null and get filled later, by a constructor, a setter or a DI container. Kotlin requires every non-null property to be initialised, so that pattern needs one of three tools:
class SupplierClient(val baseUrl: String)
class PriceSyncJob { // Set after construction by a framework or a test's @BeforeEach; never null once set. lateinit var client: SupplierClient
// Computed on first access, then cached. Thread-safe by default. (Part 6 covers delegates.) val pilotStores: List<String> by lazy { println("loading pilot stores") listOf("PUN-014", "BLR-003") }
// Genuinely optional: null is a meaningful value ("never ran"). var lastRunSummary: String? = null
fun describe(): String = if (::client.isInitialized) "client at ${client.baseUrl}" else "no client yet"
// lateinit var retries: Int // error: 'lateinit' modifier is not allowed on properties of primitive types. // lateinit val region: String // error: 'lateinit' modifier is allowed only on mutable properties.}
fun main() { val job = PriceSyncJob() println(job.describe()) // -> no client yet
try { println(job.client.baseUrl) } catch (e: UninitializedPropertyAccessException) { println(e.message) // -> lateinit property client has not been initialized }
job.client = SupplierClient("https://supplier.example/api") println(job.describe()) // -> client at https://supplier.example/api
println(job.pilotStores.size) // -> loading pilot stores // -> 2 println(job.pilotStores.first()) // -> PUN-014 println(job.lastRunSummary ?: "never ran") // -> never ran}The first pilotStores access runs the initialiser, which prints, and then size prints 2. The second access reuses the cached list.
| You have… | Use | Because |
|---|---|---|
| A value injected or set up after construction, never null afterwards | lateinit var | Non-null type at every use; fails loudly if used too early |
| An expensive value that can be computed on demand | val … by lazy { } | Computed once, cached, can stay a val |
| A value that can legitimately be absent | T? | Absence is information; make callers handle it |
lateinit works only on var properties of non-null, non-primitive types without custom accessors (members, top-level properties and local variables). ::client.isInitialized checks it, but only from code that can see the declaration lexically: the same class, an enclosing class, or the same file. Treat that check as a smell outside lifecycle code: if you need it often, the value is really optional and should be T?.
Any, Unit and Nothing
// Nothing: the return type of a function that never returns normally.fun fail(reason: String): Nothing = throw IllegalArgumentException(reason)
fun parseSku(raw: String?): String { // Because fail() returns Nothing, the Elvis expression has type String, not String?. val sku = raw?.trim()?.takeIf { it.isNotEmpty() } ?: fail("blank SKU") return sku.uppercase()}
fun parseQuantity(raw: String): Int { // error() is the standard library's fail(): it throws IllegalStateException and returns Nothing. val quantity = raw.toIntOrNull() ?: error("not a number: '$raw'") // 'if' with a throwing branch still has type Int: throw is an expression of type Nothing. return if (quantity >= 0) quantity else throw IllegalArgumentException("negative: $quantity")}
fun main() { println(parseSku(" sup-7781 ")) // -> SUP-7781 try { parseSku(" ") } catch (e: IllegalArgumentException) { println(e.message) // -> blank SKU } try { parseQuantity("twelve") } catch (e: IllegalStateException) { println(e.message) // -> not a number: 'twelve' }
// Any is the root of non-null types; Any? is the root of everything. val things: List<Any?> = listOf("rice", 5, null) println(things.map { it?.javaClass?.simpleName ?: "null" }) // -> [String, Integer, null] // things.first()!!.getClass() // error: unresolved reference 'getClass' on receiver of type 'Any'.}Any is java.lang.Object in bytecode, but its Kotlin surface is only equals, hashCode and toString. getClass() becomes javaClass (or ::class for Kotlin’s KClass). wait/notify are hidden on purpose: Kotlin wants you on java.util.concurrent or coroutines. Nothing is why ?: error(...), ?: return and TODO("...") all type-check: a branch that never completes can stand in for any type. The literal null has type Nothing?, the subtype of every nullable type, which is why it can be assigned to any of them.
==, === and arrays
fun main() { // Nullable Ints are java.lang.Integer objects, with Java's -128..127 cache. val smallA: Int? = 100 val smallB: Int? = 100 val bigA: Int? = 1000 val bigB: Int? = 1000 // The compiler warns on these: identity equality for arguments of types 'Int?' and 'Int?' is prohibited. println(smallA === smallB) // -> true println(bigA === bigB) // -> false println(bigA == bigB) // -> true
// Arrays are JVM arrays: == compares references (with a compiler warning). Use contentEquals. val todayCounts = intArrayOf(12, 0, 7) val expected = intArrayOf(12, 0, 7) println(todayCounts.contentEquals(expected)) // -> true println(todayCounts.contentToString()) // -> [12, 0, 7]
// is / !is: instanceof that also smart-casts. val payload: Any = listOf("gluten", "nuts") if (payload is List<*> && payload.isNotEmpty()) println("${payload.size} allergens") // -> 2 allergens println(payload !is String) // -> true}Part 1 showed that == is equals() and === is identity. The null-safety detail: a == b compiles to a?.equals(b) ?: (b === null), so it never throws on a null left side. The Integer-cache lines are the same trap Java has with Integer == Integer, and they happen because Int? is boxed (Part 1). The compiler says identity on numbers is “prohibited”, but only as a warning, so the code still compiles. Arrays keep Java’s identity-based equals, which matters again in Part 4: a data class with an array property doesn’t compare by content.
Step-by-Step Hands-On: A Null-Proof Supplier Importer
Code: kotlin-for-java-survivors/language/part02-the-billion-dollar-fix (file importer/SupplierImporter.kt; the Java SDK lives in src/main/java/com/supplier/sdk).
Kabir’s fix for Thursday’s incident is a boundary: every value from SupplierItem gets an explicit Kotlin type in one place, and the rest of the catalog code never sees a platform type.
Step 1 — Result types, and validation that returns null. Imported products are fully non-null. Rejections are recorded, not thrown. normaliseSku, whose bytecode appeared above, answers “is there a valid SKU here?” with a String?: for this question, null is the honest answer, and cheaper than an exception.
// Fragment of importer/SupplierImporter.ktclass ImportedProduct(val sku: String, val title: String, val allergens: List<String>) { override fun toString() = "$sku | $title | allergens=$allergens"}
class ImportReport { val imported = mutableListOf<ImportedProduct>() val rejected = mutableListOf<String>()}Step 2 — Decide nullability at the boundary. The explicit String? declaration is the whole point: the platform type stops on this line.
// Fragment of importer/SupplierImporter.kt// Step 2: every platform value gets an explicit Kotlin type at the boundary.fun titleFor(item: SupplierItem, sku: String): String { val description: String? = item.description // String! stops here return description?.trim()?.takeIf { it.isNotEmpty() } ?: "Uncatalogued item $sku"}
// A Java List<String> is (Mutable)List<String!>!: the list and each element may be null.fun allergensOf(item: SupplierItem): List<String> { val raw: List<String?>? = item.allergens return raw.orEmpty().filterNotNull().map { it.lowercase() }}A Java List<String> is two platform types nested: the list may be null, and so may each element. orEmpty() turns a null list into an empty one, and filterNotNull() returns a List<String>, so the type itself records that the nulls are gone.
Step 3 — The loop, with a smart cast doing the work.
// Fragment of importer/SupplierImporter.kt// Step 3: the import loop. After the null check, 'sku' is smart-cast to String.fun importAll(items: List<SupplierItem>): ImportReport { val report = ImportReport() for (item in items) { val sku = normaliseSku(item.code) if (sku == null) { report.rejected += "rejected code '${item.code}'" continue } report.imported += ImportedProduct(sku, titleFor(item, sku), allergensOf(item)) } return report}sku is a local val, so after if (sku == null) continue the compiler knows it is a String for the rest of the loop body. No !! anywhere.
Step 4 — Run it against a hostile feed.
// Fragment of importer/SupplierImporter.ktfun main() { val feed = listOf( SupplierItem(" sup-7781 ", "Durum Wheat Penne 500g", listOf("Gluten")), SupplierItem("SUP-7782", null, null), // not catalogued yet, allergens unknown SupplierItem("SUP-7783", " ", listOf("Milk", null, "Soy")), // blank text, a null element SupplierItem(null, "Mystery Item", listOf()), // no code at all )
val report = importAll(feed) for (product in report.imported) println(product) // -> SUP-7781 | Durum Wheat Penne 500g | allergens=[gluten] // -> SUP-7782 | Uncatalogued item SUP-7782 | allergens=[] // -> SUP-7783 | Uncatalogued item SUP-7783 | allergens=[milk, soy] println(report.rejected) // -> [rejected code 'null']}Every kind of null the SDK can produce (code, description, list, element) is handled once, at the edge, with a decision you can read.
Tips, Tricks & Gotchas
Gotcha —
val x = javaCall()keeps the platform type. Type inference doesn’t resolveString!to either side, so an inferred local holding a Java return value is unchecked, exactly as in Java. In IntelliJ, a!in the inferred type (String!) is the cue. Declareval x: String? = javaCall()at the boundary and the compiler takes over from there.
Gotcha — overriding a Java method lets you pick any nullability. When a Kotlin class implements an unannotated Java interface, say a
Comparator<Product>or a listener callback, the IDE generates parameters such asproduct: Product. That choice is unchecked against what the Java caller actually passes. If the framework can passnull, declare the parameterProduct?, or the entry check fails withParameter specified as non-null is null.
Gotcha —
Anyhas nogetClass(). Usex.javaClassfor the Java class orx::classfor the KotlinKClass.wait()/notify()are hidden too. If you are porting code that uses them, it’s time forjava.util.concurrent.
Tip —
?.letfor one expression, an early return for everything else.rice.brand?.let { … }is ideal at the end of a chain. For a local that must be non-null for the rest of a function,val sku = normaliseSku(raw) ?: return null(orif (sku == null) return …) gives it a non-null type for every line that follows, without nesting the rest of the function inside a lambda.
Debugging: Reading a Kotlin NPE
The message tells you where the null came from:
| Message | Origin | Fix |
|---|---|---|
getDescription(...) must not be null | A platform value used as non-null; Kotlin’s boundary check | Declare the value T? at the boundary, or annotate the Java side |
Parameter specified as non-null is null: method …normalise, parameter sku | Java passed null into a Kotlin function | Fix the Java caller, or make the parameter T? if null is legitimate |
Cannot invoke "String.length()" because "inferred" is null (or …because the return value of "…getDescription()" is null) | A JVM “helpful NPE”: a platform value held in an inferred local, or dereferenced directly | Same as the first row; this one failed later than it could have |
null (no message) | !! | Replace with ?: error("…") |
lateinit property client has not been initialized | lateinit read before assignment | Check the lifecycle (DI wiring, test setup) |
null cannot be cast to non-null type kotlin.String | x as String with x == null | as String?, or as? |
Key Takeaways
| Concept | Remember |
|---|---|
| Nullable types | T and T? are different types; T is a subtype of T? |
| Operators | ?. short-circuits, ?: falls back (and can return/throw), as? casts safely, !! asserts |
| Runtime cost | None for reference types (same JVM reference plus @Nullable); Int? and other nullable primitives box |
| Inserted checks | Kotlin checks non-null parameters on entry and platform values at use |
| Smart casts | Only for values that can’t change: copy properties to a local val first |
| Platform types | T! = unknown nullability from Java; decide explicitly at the boundary |
| JSpecify | Annotated Java becomes real T/T?; mismatches are errors; Spring 7 is annotated |
lateinit / lazy / T? | Set later / computed once / genuinely optional |
Any?, Nothing | Top and bottom types; Nothing makes ?: error() type-check |
| Equality | == is null-safe equals, === is identity, arrays need contentEquals |
Story Closing
The fix went out on Friday morning. shelfTitle now took an ImportedProduct, and the only code that touched SupplierItem was a forty-line importer with every nullable decision written down. Kabir also opened a ticket with the supplier asking for @NullMarked. He didn’t expect an answer: tickets like that live in a supplier’s backlog for years.
Then he turned to the real job: the Product class for the catalog service. He opened a new file and, from muscle memory, started typing private final String name;, then a constructor, then public String getName().
Lena rolled her chair over. “Stop. Before you write a single getter,” she said, “let me show you where Kotlin keeps its fields.”
In Part 3, Kabir learns that in Kotlin there are no fields to declare, only properties.
This is Part 2 of a 16-part series: “Kotlin for Java Survivors: Life After Semicolons.”