JSpecify mode: inference substitutes a synthetic `@NonNull` for an unconstrained type variable, so a `List<T>` with `T extends @Nullable Object` flows into a `List<Object>`
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 5/5
- Estimated time
- Over a week
- Newbie friendliness
- 12/100
- Issue type
- Bug
- Clarity
- Mostly clear
- Activity status
- Active
- Tech stack
- java
- Domain
- data-engineering
Research direction
Start in ConstraintSolverImpl.solve, where UNKNOWN nullness of inferred type variables maps to NONNULL, and in TypeSubstitutionUtils.substituteInferredNullabilityForTypeVariables, which stamps the synthetic @NonNull. Reproduce with the JSpecify test helper in nullaway/src/test/java/com/uber/nullaway/jspecify/ using the diamond and idBox cases. Done means a diagnostic on lines 12 and 15 without breaking the new ArrayList/HashSet cases #1834 keeps compiling; the fix needs a third NO_CHANGE result, so it is design work.
Written by the indexing model from the issue text.
Description
With JSpecify mode on, new ArrayList<>(in) with in a List<T> and T extends @Nullable Object is accepted where a List<Object> is required, so the caller below throws NullPointerException at copy(in).get(0).toString() with no diagnostic anywhere. The same List<T> passed directly to a Collection<? extends Object> parameter is reported once #1834 is applied; the diamond, and any generic method whose type variable is instantiated by that argument, are not.
Versions: NullAway master at 2b25d0fc8 (0.14.3-SNAPSHOT), the 0.14.2 release, and the head of #1834 (b84ffe738). Error Prone 2.50.0, JDK 21.0.9. Flags: -XepOpt:NullAway:JSpecifyMode=true -XDaddTypeAnnotationsToSymbol=true -XepOpt:NullAway:JSpecifyExperimental=true -XepOpt:NullAway:OnlyNullMarked=true.
Code sample. I ran it as a test in nullaway/src/test/java/com/uber/nullaway/jspecify/, with the helper WildcardTests uses: makeTestHelperWithArgs(JSpecifyJavacConfig.withJSpecifyModeArgs(Arrays.asList("-XepOpt:NullAway:OnlyNullMarked=true"))).
import java.util.ArrayList;
import java.util.List;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@NullMarked
class Test {
static class Box<E extends @Nullable Object> {}
static <U extends @Nullable Object> Box<U> idBox(Box<U> b) {
return b;
}
static <T extends @Nullable Object> List<Object> copy(List<T> in) {
return new ArrayList<>(in);
}
static <T extends @Nullable Object> void boxed(Box<T> box) {
Box<? extends Object> x = idBox(box);
}
static void caller() {
List<@Nullable String> in = new ArrayList<>();
in.add(null);
copy(in).get(0).toString();
}
}
Expected: a diagnostic on the return new ArrayList<>(in); line (line 12) and on the Box<? extends Object> x = idBox(box); line (line 15), of the form #1834 gives a Box<T> passed straight to a Box<? extends Object> parameter:
incompatible types: Box<T> cannot be converted to Box<? extends Object>
Under JSpecify subtyping a bare T whose declared bound is @Nullable Object is not null-exclusive, so List<T> is contained by Collection<? extends E> only where E admits null; ArrayList<Object> has E as Object, and Box<? extends Object> requires the same of the U that idBox returns. The caller method shows the consequence: T is instantiated as @Nullable String, which NullAway accepts, and the List<Object> that comes back holds a null.
Actual: no diagnostic on any line, on master, on 0.14.2, and with #1834 applied. With a // BUG: Diagnostic contains: incompatible types marker inserted above line 12, which shifts it to line 13, the test fails with:
Did not see an error on line 13 matching incompatible types. There were no errors.
Analysis, a guess; the report stands without it. For the diamond and for idBox, javac infers the type variable as T and NullAway's ConstraintSolverImpl infers its nullness separately. Nothing in the call constrains it: T is neither @Nullable nor known non-null, so the variable stays UNKNOWN, and solve() maps UNKNOWN to NONNULL ("we infer NONNULL arbitrarily", ConstraintSolverImpl.solve). TypeSubstitutionUtils.substituteInferredNullabilityForTypeVariables then stamps a synthetic @NonNull on the variable, so the checks see ? extends @NonNull E on the formal and Box<@NonNull U> on the result, and a bare T passes against both. #1834 has to read that synthetic @NonNull as "no decision" to keep List<T> copy = copyOf(in) and new HashSet<>(in) compiling, which is correct for those and is what hides this case.
A result with two values cannot express what javac inferred here: the nullness of E is the nullness of T, whatever T is instantiated as. A third result, "same as the javac-inferred type" (the specification's NO_CHANGE), would substitute no annotation at all, and the containment check would then judge T against ? extends Object by its declared bound, as it does for the direct call. The dereference first(b).toString() with <U extends @Nullable Object> U first(Box<? extends U> b) goes unreported for the same reason, but it also meets the bare-T dereference gap of #1727, so it is not the reproducer here.
Related: #1834 reports the direct, non-inferred shape and names this gap in its description. #1551 asks for inference failures to be reported by default, where the default @NonNull hides an error; this report is the case where inference does not fail and the default still hides one. #1585 proposes mapping type variables to full nullability-enhanced types, which is where a NO_CHANGE result would live. #1727 is the NO_CHANGE operator on the dereference and sink side.
Assisted-by: Claude Code (claude-fable-5-1)
- Dominant language
- Java
- Stars
- 4.1k
- Forks
- 379
- Avg merge
- 1d 2h
- Merged PRs (30d)
- 106
Getting set up
- No Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from uber/NullAway
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
uber/NullAway#2145 · 1 reaction ·
Maintainers usually reply within 1 day
-
Difficulty 1/5 1-3 hours Newbie friendliness 85/100
uber/NullAway#2058 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
uber/NullAway#1899 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 5/5 Over a week Newbie friendliness 15/100
uber/NullAway#2160 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 3/5 1-2 days Newbie friendliness 62/100
Maintainers usually reply within 1 day
Similar issues
-
Difficulty 1/5 Under an hour Newbie friendliness 88/100
johanhaleby/occurrent#1505 ·
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
FasterXML/jackson-databind#6443 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 80/100
finos/fluxnova-bpm-platform#387 ·
Maintainers usually reply within 4 days
-
Difficulty 1/5 Under an hour Newbie friendliness 88/100