Summary
When a constructor (or method) takes a Class<T> type token and T is inferred as a @Nullable type via the diamond operator, NullAway rejects passing a plain class literal such as String.class:
[NullAway] incompatible types: Class<String> cannot be converted to Class<@Nullable String>
Since there is no way to write a Class<@Nullable String> literal in Java (String.class is always Class<String>, and nullness is not reified on a class token), I'd like to confirm whether this is intended behavior or a false positive that should be special-cased for Class<T> type tokens.
Minimal reproducer
package com.example;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@NullMarked
public class ClassTokenRepro {
static class Signal<T extends @Nullable Object> {
private final Class<T> valueType;
Signal(Class<T> valueType) {
this.valueType = valueType;
}
}
void caller() {
// T = String -> param Class<String>, arg Class<String>: OK
Signal<String> nonNull = new Signal<>(String.class);
// T = @Nullable String -> param Class<@Nullable String>, arg Class<String>.
// ERROR: [NullAway] incompatible types:
// Class<String> cannot be converted to Class<@Nullable String>
Signal<@Nullable String> nullable = new Signal<>(String.class);
}
}
An explicit type witness does not change the result (the parameter type is still Class<@Nullable String>):
Signal<@Nullable String> nullable = new Signal<@Nullable String>(String.class); // same error
The only way we found to satisfy the checker is an explicit type-use cast, which is erased at runtime to plain String.class:
Signal<@Nullable String> nullable = new Signal<>((Class<@Nullable String>) String.class); // OK
Why this looks questionable
Class<T> is invariant, so strictly Class<String> is not a Class<@Nullable String>.
- However, a
Class instance is a runtime type token. Nullness is not part of the runtime class, and there is no Class<@Nullable String> literal to produce. So for a Class<T> parameter, the nullness of T arguably carries no meaning and the only available argument is a Class<NonNullVersionOfT>.
- This makes any API of the shape
Foo(Class<T> type) with T extends @Nullable Object effectively unusable with a nullable T without a cast.
When it changed
The change first appears in 0.13.2, which lines up with the "Initial handling of constructor diamond operators" work (PR #1464): before that the diamond construction was not analyzed, so the nullable case compiled.
Question
Is this the intended behavior for Class<T> (and similar runtime type-token) or just how it happens to work right now?
Summary
When a constructor (or method) takes a
Class<T>type token andTis inferred as a@Nullabletype via the diamond operator, NullAway rejects passing a plain class literal such asString.class:Since there is no way to write a
Class<@Nullable String>literal in Java (String.classis alwaysClass<String>, and nullness is not reified on a class token), I'd like to confirm whether this is intended behavior or a false positive that should be special-cased forClass<T>type tokens.Minimal reproducer
An explicit type witness does not change the result (the parameter type is still
Class<@Nullable String>):The only way we found to satisfy the checker is an explicit type-use cast, which is erased at runtime to plain
String.class:Why this looks questionable
Class<T>is invariant, so strictlyClass<String>is not aClass<@Nullable String>.Classinstance is a runtime type token. Nullness is not part of the runtime class, and there is noClass<@Nullable String>literal to produce. So for aClass<T>parameter, the nullness ofTarguably carries no meaning and the only available argument is aClass<NonNullVersionOfT>.Foo(Class<T> type)withT extends @Nullable Objecteffectively unusable with a nullableTwithout a cast.When it changed
The change first appears in 0.13.2, which lines up with the "Initial handling of constructor diamond operators" work (PR #1464): before that the diamond construction was not analyzed, so the nullable case compiled.
Question
Is this the intended behavior for
Class<T>(and similar runtime type-token) or just how it happens to work right now?