skip to content

You consume a large unannotated Java library whose methods all return platform types. How do you design the Kotlin side so platform-type NPEs don't leak deep into your code?

level: seniorimportance: should knowfreq 34%

answer

  1. Treat unannotated Java as untrusted boundary
  2. Thin facade / anti-corruption layer
  3. Pin types: explicit String?, requireNotNull/checkNotNull
  4. Avoid blanket !!; use messages or Elvis fallback
  5. External annotations / JSpecify to self-describe the API

basics

~20 s

Wrap the Java library behind a thin Kotlin layer. At that boundary, give every value an explicit nullable or non-null type and validate it, so the rest of your code only ever sees proper Kotlin types and never raw platform types.

solid answer

~50 s

Treat the unannotated Java API as an **untrusted boundary** and build a thin Kotlin **adapter/facade** in front of it. At the adapter, **never let platform types flow inward**: assign each Java result to an explicit type. Use `?` for values that can be null and validate the rest with `requireNotNull`/`checkNotNull` (which throw a clear message at the boundary instead of a mystery NPE deep inside). Convert Java collections, dates, etc. into your own domain types right there. Avoid blanket `!!`; reserve it for cases you have genuinely proven non-null. For inputs going back into Java, validate before the call. The result: internal code sees only real Kotlin `String`/`String?` types with full smart-cast and null-safety support, and any contract violation fails fast with a readable error at the edge, not as a late NPE. If you can, also contribute JSpecify/`@Nullable` annotations or external annotation XML to make the boundary self-describing.

go deeper

for a junior

Knows to assign Java results to explicit nullable types and null-check them.

for a middle

Introduces requireNotNull/checkNotNull for fail-fast and avoids scattering !!.

for a senior

Designs a thin anti-corruption facade that localizes platform types and converts foreign shapes into domain types.

for a principal

Weighs external-annotation tooling, library forking/annotation contributions, and team conventions to make the whole boundary self-describing and CI-enforced.

## The risk An unannotated Java library hands Kotlin **platform types** (`String!`). If those flow untouched into your domain logic, the compiler's null-safety is silently off along that path, and a real null surfaces as a **late NPE** far from the Java call that produced it. ## Strategy: an anti-corruption boundary Build a **thin Kotlin facade** (adapter / anti-corruption layer) that is the *only* place that touches the Java API. Inside the facade you **pin nullability explicitly**; outside it, only real Kotlin types exist. ### 1. Assign explicit types immediately ```kotlin // Java: User getUser(); String getName(); (all platform types) fun loadUser(id: Long): DomainUser { val raw = legacyApi.getUser(id) ?: error("legacy getUser($id) returned null") // pin nullability val name: String = requireNotNull(raw.name) { // fail fast, clear msg "User $id has null name from legacy API" } val email: String? = raw.email // genuinely optional return DomainUser(name = name, email = email) } ``` - `requireNotNull` / `checkNotNull` throw `IllegalArgumentException`/`IllegalStateException` with a **message at the boundary** — far better diagnostics than a bare NPE later. - The Elvis `?:` plus `error(...)` does the same for whole objects. ### 2. Don't let `!!` sprawl `!!` converts a platform/nullable value to non-null and throws if null. It's acceptable **only** when you've truly proven non-null; sprinkling it everywhere just relocates the same NPE with no message. Prefer `requireNotNull` for the message, or `?:` for a fallback. ### 3. Convert types, not just nullability Map Java collections, `Optional`, `java.util.Date`, etc. into your domain/Kotlin types at the edge so inner code never deals with the foreign shapes either. ### 4. Validate outbound arguments too Before calling back into Java, validate your arguments (`require(...)`), since the Java side may have undocumented non-null expectations. ### 5. Make the boundary self-describing if you can - Add **JSpecify** / `@Nullable` annotations if you can patch or fork the library. - Otherwise use **external annotations** (IntelliJ annotation XML / `@Nullable` stubs) so tooling treats results as nullable. ## Why this works All platform-type ambiguity is **localized** to one small, well-tested layer. The rest of the codebase enjoys full Kotlin null-safety, smart casts, and exhaustive handling, and failures are **loud and local** instead of silent and remote.

  • Why prefer requireNotNull over !! at the boundary?
    Both throw on null, but requireNotNull lets you attach a descriptive message identifying the source, turning a cryptic NPE into a diagnosable failure at the edge.
  • What if you can't modify the Java library to add annotations?
    Use IntelliJ external annotations (annotation XML) or wrapper stubs so tooling and the compiler treat the results as nullable, plus your facade still pins types defensively.

It's customs at a border: inspect and stamp every package once at the edge, so nothing unverified roams the interior.

saying these in an interview costs you the question

  • Spraying !! across the codebase to silence platform-type warnings
  • Letting platform types flow directly into domain logic
  • Assuming the compiler will catch a null from an unannotated Java method
  • No boundary layer — Java types and nullability leak everywhere
  • Using !! as a default conversion with no proof of non-null

context