Skip to content

Spurious inference failure when a generic method returns @Nullable T into a non-null target type #1730

Description

@vlsi

Version

NullAway 0.14.0, and current master (21f02dd). Error Prone 2.50.0, JDK 21.

Flags: -XepOpt:NullAway:JSpecifyMode=true -XDaddTypeAnnotationsToSymbol=true, with WarnOnGenericInferenceFailure on. It defaults to JSpecifyExperimental, so JSpecifyExperimental=true turns it on.

Summary

When a generic method's return type is @Nullable T, the inference check appears to read that @Nullable as a constraint on T itself. Combined with a non-null target type, it produces

inference failure: type variable T constrained to be both @NonNull and @Nullable

The arguments are irrelevant — a call with only non-null arguments reports just the same. A nullable target type does not report, and an explicit type witness does not report.

Standing alone this is a misleading second diagnostic next to a real one (compare #1721). It becomes a plain false positive when a @Contract makes the result non-null: the genuine finding correctly disappears, and the spurious inference failure is all that is left.

Reproducer: false positive

package foo;

import org.jetbrains.annotations.Contract;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

@NullMarked
class Test {
  @Contract("_, !null -> !null")
  static <T extends @Nullable Object> @Nullable T first(@Nullable T v0, @Nullable T v1) {
    return v0 != null ? v0 : v1;
  }

  @Contract("_, !null -> !null")
  static @Nullable String firstString(@Nullable String v0, @Nullable String v1) {
    return v0 != null ? v0 : v1;
  }

  static String useGeneric(@Nullable String a) {
    // BUG: Diagnostic contains: inference failure: type variable T constrained to be both @NonNull and @Nullable
    return first(a, "");
  }

  static String useConcrete(@Nullable String a) {
    return firstString(a, "");        // no finding
  }

  static String useWitness(@Nullable String a) {
    return Test.<String>first(a, ""); // no finding
  }
}

The inference failure is the only diagnostic on useGeneric. The contract is honoured everywhere else: no returning @Nullable expression is reported, and -XepOpt:NullAway:WarnOnGenericInferenceFailure=false leaves the method completely clean. So the code is right and the two checks disagree.

Reproducer: the underlying misdiagnosis, no contract involved

package foo;

import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

@NullMarked
class Test {
  static <T extends @Nullable Object> @Nullable T id(T v) {
    return v;
  }

  static String nonNullTarget() {
    // BUG: Diagnostic contains: inference failure: type variable T constrained to be both @NonNull and @Nullable
    // BUG: Diagnostic contains: returning @Nullable expression from method with @NonNull return type
    return id("");
  }

  static @Nullable String nullableTarget() {
    return id("");                    // no finding
  }

  static String witness() {
    // BUG: Diagnostic contains: returning @Nullable expression from method with @NonNull return type
    return Test.<String>id("");
  }
}

T = String satisfies every constraint here. The result is nullable because the return type is @Nullable T, not because T is nullable, and the second finding on nonNullTarget says so correctly. The inference failure adds a conflict that does not exist, and the witness form shows it: with T pinned to non-null String, the same call is accepted and only the genuine finding remains.

What does and does not report

Call Inference failure
String s = id("") — non-null target yes
@Nullable String s = id("") — nullable target no
String s = Test.<String>id("") — explicit witness no
String s = first(a, b) — both arguments non-null yes
@Nullable String s = first(a, b) — both arguments nullable no
T m(@Nullable T v0, T v1) — return type not @Nullable no

Expected

@Nullable on a use of T in the return type constrains the result, not T. Inferring T = String for id("") in a String context should succeed, leaving the nullability of the result to the checks that already handle it correctly.

Workaround

Write the type argument out, or declare the method so the signature states the same thing without a contract:

static <T extends @Nullable Object> T firstNonNull(@Nullable T v0, T v1) {
  return v0 != null ? v0 : v1;
}

T is inferred non-null from the second argument, @Nullable T still accepts a nullable first argument, and the result is non-null. Nothing reports.

Where we hit it

Util.first is the Elvis operator of the codebase, and 19 of its call sites report. Found while replacing the Checker Framework with NullAway in Apache Calcite (apache/calcite#5213).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions