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

JavaKotlinNote
String s (may be null, who knows)String / String?Nullability is part of the type
if (p != null) p.getCode()p?.codeSafe call: null if p is null
x != null ? x : fallbackx ?: fallbackElvis operator
Objects.requireNonNull(x)x!! or x ?: error("why")Prefer the message
(String) o in a try/catcho as? Stringnull instead of ClassCastException
Optional<T>T?No wrapper object; works for fields and parameters
instanceof + castis + smart castNo explicit cast after the check
ObjectAny / Any?Any? is the true top type
voidUnitA type with one value
(no equivalent)NothingThe type of throw and of functions that never return
a.equals(b)a == bNull-safe
a == b (references)a === bIdentity
Arrays.equals(a, b)a.contentEquals(b)Arrays don’t override equals
@Nullable from JSpecifyRead 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:

  • String holds a string. Always. The compiler enforces it.
  • String? holds a string or null. 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:

graph TD AnyN["Any? — top: every value, including null"] --> Any["Any — every non-null value"] AnyN --> SN["String?"] AnyN --> PN["Product?"] Any --> S["String"] Any --> P["Product"] SN --> S PN --> P SN --> NN["Nothing? — only the value null"] PN --> NN S --> N["Nothing — no values at all: throw, error(), TODO()"] P --> N NN --> N

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.Optional
import 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, if a is null the result is null and b is 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 be return, continue or throw.
  • !! is an assertion, not a conversion. It throws NullPointerException with a null message, 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.Nullable
public 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: areturn

Each ?. 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 module
System.out.println(SkuRulesKt.normalise(" sup-7781 ")); // -> SUP-7781
try {
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 checked

uppercase() 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 local vars not captured by a lambda that modifies them;
  • val properties with no custom getter that are not open, 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:

package-info.java
@NullMarked
package com.supplier.sdk.v2;
// SupplierItemV2.java
public @Nullable String getDescription() { return description; }
public String getCode() { return code; } // non-null: the package default

Kotlin 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:jspecify and put @NullMarked on 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…UseBecause
A value injected or set up after construction, never null afterwardslateinit varNon-null type at every use; fails loudly if used too early
An expensive value that can be computed on demandval … by lazy { }Computed once, cached, can stay a val
A value that can legitimately be absentT?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.kt
class 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.kt
fun 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 resolve String! 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. Declare val 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 as product: Product. That choice is unchecked against what the Java caller actually passes. If the framework can pass null, declare the parameter Product?, or the entry check fails with Parameter specified as non-null is null.

Gotcha — Any has no getClass(). Use x.javaClass for the Java class or x::class for the Kotlin KClass. wait()/notify() are hidden too. If you are porting code that uses them, it’s time for java.util.concurrent.

Tip — ?.let for 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 (or if (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:

MessageOriginFix
getDescription(...) must not be nullA platform value used as non-null; Kotlin’s boundary checkDeclare the value T? at the boundary, or annotate the Java side
Parameter specified as non-null is null: method …normalise, parameter skuJava passed null into a Kotlin functionFix 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 directlySame as the first row; this one failed later than it could have
null (no message)!!Replace with ?: error("…")
lateinit property client has not been initializedlateinit read before assignmentCheck the lifecycle (DI wiring, test setup)
null cannot be cast to non-null type kotlin.Stringx as String with x == nullas String?, or as?

Key Takeaways

ConceptRemember
Nullable typesT 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 costNone for reference types (same JVM reference plus @Nullable); Int? and other nullable primitives box
Inserted checksKotlin checks non-null parameters on entry and platform values at use
Smart castsOnly for values that can’t change: copy properties to a local val first
Platform typesT! = unknown nullability from Java; decide explicitly at the boundary
JSpecifyAnnotated Java becomes real T/T?; mismatches are errors; Spring 7 is annotated
lateinit / lazy / T?Set later / computed once / genuinely optional
Any?, NothingTop 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.”