skip to content

When you override a Java method that returns a platform type in Kotlin, what nullability must your override declare, and how do platform types interact with generic type parameters?

level: principalimportance: nice to knowfreq 22%

answer

  1. Can't declare a platform type -> override must pick concrete nullability
  2. Compiler accepts either nullable or non-null choice
  3. Non-null param -> intrinsic checkNotNullParameter throws on null
  4. Java List<String> -> List<String!> (element platform-typed)
  5. Annotations/JSpecify propagate into the type argument

basics

~20 s

When you override a Java method in Kotlin, you must pick a concrete nullability — nullable or non-null — for the return and parameters; you can't leave it as a platform type. With generics, Kotlin substitutes the platform nullability into the type argument the same way.

solid answer

~50 s

A platform type only exists at the **Java-to-Kotlin call boundary**; you **cannot declare** one in Kotlin. So when you override a Java method whose signature is unannotated, Kotlin **forces you to choose** an explicit nullability for each parameter and the return type. The compiler accepts either a nullable (`String?`) or non-null (`String`) choice because the Java contract is unknown — but once you pick, that becomes the Kotlin-visible contract callers rely on, so picking non-null on something that can return null reintroduces the NPE risk on your own API. For **generics**, an unannotated Java type parameter behaves like a platform type per use site: `T` from a Java `List<T>` arrives as a platform-typed element (`T!`), and you again pin it by typing the receiving variable (`List<String?>` vs `List<String>`). Recognized annotations or JSpecify propagate into the type argument so the choice is made for you. The design lesson: an override is where you **commit** the ambiguous Java contract into a precise Kotlin one, so choose deliberately.

code

kotlin · 15 lines
kotlin
// Java (unannotated):
//   interface Cache<V> { V get(String key); List<V> all(); }

class MyCache : Cache<String> {
    // Override forces a concrete nullability choice:
    override fun get(key: String): String? = backing[key]    // chose nullable
    override fun all(): List<String> = backing.values.toList() // chose non-null elements
}

// Consuming an unannotated Java generic:
fun use(c: Cache<String>) {
    val v = c.get("x")          // String! at the Java boundary
    val pinned: String? = v     // commit to nullable
    println(pinned?.length)
}

go deeper

for a junior

Knows an override must declare a concrete (nullable or non-null) type, not a platform type.

for a middle

Explains both choices compile and that the choice becomes the Kotlin-visible contract callers depend on.

for a senior

Connects the parameter intrinsic null check and generic element platform-typing (List<String!>) to where NPEs can resurface.

for a principal

Treats the commit point as an API-design decision and prefers fixing it at the source via JSpecify annotations or a centralized facade across the team.

## Overriding a Java method: you must commit A **platform type** is not a declarable type — it only appears implicitly when a value crosses from Java. Therefore, when a Kotlin class **overrides** an unannotated Java method, the compiler requires you to write a **concrete** type for every parameter and the return: ```kotlin // Java interface: // interface Parser { String parse(String input); } // unannotated class StrictParser : Parser { // You MUST choose nullability; both compile because Java contract is unknown: override fun parse(input: String): String { ... } // non-null choice // or // override fun parse(input: String?): String? { ... } // nullable choice } ``` Kotlin is **lenient about the choice** (it won't reject either, since the Java side is ambiguous), but the choice becomes the **real contract** your Kotlin callers see and smart-cast against. If you declare a non-null return but the underlying logic can yield null, you have pushed the platform-type NPE risk into your own API surface. ## Parameters: be careful with non-null Declaring a parameter non-null means Kotlin callers get null-safety, but if **Java code** still calls your override and passes null, the compiler-generated **intrinsic null check** (Kotlin inserts `Intrinsics.checkNotNullParameter`) throws immediately. That's usually desirable (fail fast) but is a behavior change to be aware of. ## Generics and platform types For an unannotated Java generic, the **type argument** carries platform nullability per use: ```kotlin // Java: List<String> getTokens(); (unannotated) val tokens = javaObj.tokens // List<String!> -> elements are platform-typed for (t in tokens) { // t is String! here; pin it: val s: String = t // trust non-null (may NPE if element null) val maybe: String? = t // treat element as nullable } ``` - A bare Java `List<String>` becomes Kotlin `(Mutable)List<String!>`: both the container and the element nullability are relaxed. - You commit element nullability by typing the receiver (`List<String?>` vs `List<String>`), exactly like a scalar platform type. - **Annotations propagate**: `List<@Nullable String>` (JSpecify) -> `List<String?>`; `@NonNull List<@NonNull String>` -> `List<String>`. The platform-type leniency disappears once the type is annotated. ## Design takeaway (principal lens) An override or a generic substitution is the **commit point** where the unknown Java contract becomes a precise Kotlin one. Treat it like an API decision: annotate the Java source (JSpecify) so the choice is made correctly and uniformly, rather than each override guessing. Where you can't annotate, document and centralize the choice in a facade so the same value isn't typed non-null in one place and nullable in another.

  • Why does the compiler let you declare either nullable or non-null when overriding an unannotated Java method?
    Because the Java contract is unknown (platform type), Kotlin can't prove either choice wrong, so it permits both and lets you commit the contract; annotations would constrain it to one.
  • What runtime mechanism enforces a non-null parameter you declared on the override when Java calls it with null?
    Kotlin inserts an intrinsic null check (Intrinsics.checkNotNullParameter) at method entry, which throws immediately with the parameter name.

An override is signing a contract whose Java draft left the nullability blank — once you sign non-null, you're liable for any null that slips through.

saying these in an interview costs you the question

  • Claiming you can keep the override's type as a platform type (String!)
  • Saying the compiler forces nullable on every Java override
  • Thinking generic elements of an unannotated Java List are guaranteed non-null
  • Ignoring that a non-null parameter adds a runtime intrinsic check
  • Believing annotations don't propagate into type arguments

context