How do nullability annotations interact with Java generics and array element types when imported into Kotlin, and what are the common gotchas?
answer
- Annotation binds to the position it's written
- Container @NotNull != non-null elements
- Element nullability needs TYPE_USE target
- Raw types -> everything platform
- JSpecify @NullMarked handles generics cleanly
basics
~20 sAnnotations can mark not just the outer type but also the element type inside a generic or array. If only the container is marked, the elements may still be platform types, so a list can be non-null while its items aren't checked.
solid answer
~40 sNullability annotations apply at the position they're written. A Java `@NotNull List<String>` makes the *list itself* non-null, but the element type `String` is un-annotated and stays a platform type, so `list[0]` is `String!`, not guaranteed non-null. To pin element nullability, Java must use type-use annotations (TYPE_USE targets, e.g. `List<@NotNull String>` or `@Nullable String[]`) which Kotlin reads to produce `List<String>` vs `List<String?>`. JetBrains and modern JSR-305-style annotations support TYPE_USE; older ones only target the declaration. Gotchas: (1) container-only annotation leaves element platform types; (2) raw types erase everything to platform types; (3) varargs and array element nullability need TYPE_USE; (4) JSpecify is the emerging standard precisely because it specifies generic/type-parameter nullability cleanly. The practical rule: don't assume `@NotNull List<T>` gives you `List<T>` non-null elements — verify the element annotation.
code
kotlin · 10 lines// Java:
// @NotNull List<String> a(); // container only
// List<@Nullable String> b(); // TYPE_USE on element
fun demo(o: Demo) {
val a = o.a() // List<String!> : elements are platform
val e: String = a[0] // compiles; NPE risk if element null
val b = o.b() // List<String?> : element nullability precise
val f: String = b[0] // COMPILE ERROR: b[0] is String?
}go deeper
May not know the distinction; at most recalls that collections from Java can still hold nulls.
Understands container vs element annotation and that elements can remain platform types.
Explains TYPE_USE targeting, array/varargs element nullability, raw-type erasure, and points to JSpecify.
Chooses an annotation standard for a published API considering generics/variance, plans JSpecify adoption with @NullMarked, and reasons about cross-tool consistency (Kotlin, NullAway, IDE).
## Annotations are positional A nullability annotation constrains exactly the type at the position it's attached. With generics and arrays there are *multiple* positions: the container and each element/type argument. ### Container vs element ```java // Only the container is annotated: @NotNull List<String> names(); ``` Kotlin imports this as `(Mutable)List<String!>` — the **list** is non-null, but each element is a **platform type** `String!` because the inner `String` carries no annotation: ```kotlin val xs = obj.names() // List<String!> (list non-null) val first: String = xs[0] // compiles, but NPE at runtime if element is null ``` This is a classic trap: you feel "safe" because the list is `@NotNull`, but element nullability was never specified. ### Type-use annotations pin elements Modern annotations declared with `@Target(ElementType.TYPE_USE)` can sit on the **inner** type: ```java List<@NotNull String> a(); // Kotlin: List<String> List<@Nullable String> b(); // Kotlin: List<String?> @Nullable String[] c(); // element-nullable array -> Array<String?> ``` Kotlin reads TYPE_USE-positioned annotations to give precise element nullability. JetBrains `org.jetbrains.annotations` (2.x) and JSpecify support TYPE_USE; some legacy annotations only target `METHOD`/`PARAMETER`/`FIELD` declarations and therefore can't describe element types. ## Arrays and varargs Array *element* nullability also needs a TYPE_USE annotation on the component type. A bare `@NotNull String[]` typically means the array reference is non-null, not its elements. Varargs (`String...`) follow array rules. ## Raw types erase everything A Java **raw type** (e.g. `List` with no type argument) loses all generic info; Kotlin sees `(Mutable)List<*>!` with platform element types regardless of annotations on neighboring code. ## JSpecify: the modern answer Generic nullability — type parameters, bounds, wildcards — is genuinely hard. **JSpecify** (`org.jspecify.annotations.Nullable`/`NonNull` with `@NullMarked` module/package defaults) was created to specify exactly these cases consistently across tools. Kotlin has growing JSpecify support and it's the recommended direction for new Java APIs that want clean Kotlin interop. ## Practical rules - Don't assume `@NotNull List<T>` means non-null elements — check the element annotation. - Prefer TYPE_USE annotations (or JSpecify `@NullMarked`) when authoring Java for Kotlin consumers. - Treat raw types as fully un-typed/platform. - When in doubt, inspect the imported Kotlin type (IDE shows `List<String?>` vs `List<String!>`).
- Why does @NotNull List<String> not give you non-null elements in Kotlin?The annotation only constrains the List type itself; the inner String has no annotation, so it imports as a platform type (String!). You need a TYPE_USE annotation on the element, like List<@NotNull String>.
- What is JSpecify and why is it relevant here?A modern, tool-agnostic nullability annotation standard (with @NullMarked defaults) designed to specify generics, type parameters and bounds precisely. Kotlin supports it and it's the recommended choice for new Java APIs targeting Kotlin interop.
saying these in an interview costs you the question
- Assuming a @NotNull collection guarantees non-null elements
- Not knowing TYPE_USE is required for element-level nullability
- Ignoring that raw types erase to platform types
- Being unaware of JSpecify for generic nullability
- Claiming Kotlin always infers element nullability from the container annotation