NullAway icon indicating copy to clipboard operation
NullAway copied to clipboard

Nullables are not passed correctly in a generic context in Jspecify mode

Open Marinell0 opened this issue 2 months ago • 2 comments

We are moving to jspecify as our null annotation method and we are discovering some issues.

We added these flags to our project:

-XepOpt:NullAway:JSpecifyMode=true
-XDaddTypeAnnotationsToSymbol=true

To start using the jspecify mode, as per specification.

The problem we are having is that the @Nullable type is not passed to another generic type from a class or method.

False positive example

import java.util.List;
import java.util.function.Function;

import org.jspecify.annotations.Nullable;

public interface NullAwayExample<T> {
    default void doSomething(List<@Nullable T> input) {
        doSomething(input, Function.identity());
    }

    <U> void doSomething(List<@Nullable U> input, Function<@Nullable U, @Nullable T> mapper);
}

This gives me a warning:

[NullAway] Cannot pass parameter of type Function<U, U>, as formal parameter has type Function<@org.jspecify.annotations.Nullable U, @org.jspecify.annotations.Nullable T>, which has mismatched type parameter nullability

The Function.identity() has the signature:

    /**
     * Returns a function that always returns its input argument.
     *
     * @param <T> the type of the input and output objects to the function
     * @return a function that always returns its input argument
     */
    static <T> Function<T, T> identity() {
        return t -> t;
    }

So the problem that I'm seeing is that the <T> in the Function<T, T> or any function that receives a generic from outside the method loses the @Nullable annotation on it's definition.

In this case, the warning is a false positive as the generic type <T> will be returned in both cases, and as I'm giving it a @Nullable Object, it should return a @Nullable Object of the same type, as the lambda t -> t implies.

False negative example

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;

import org.jspecify.annotations.Nullable;

@SuppressWarnings("SystemOut")
public final class NullAwayExample {
    private NullAwayExample() {}

    public static void main(String[] args) throws InterruptedException, ExecutionException {
        List<Callable<@Nullable Throwable>> tasks = new ArrayList<>();

        tasks.add(() -> null);

        try (var exec = Executors.newFixedThreadPool(1)) {
            @Nullable Throwable result = exec.invokeAny(tasks);

            String message = result.getMessage();
            System.out.println(message);
        }
    }
}

This compiles and NullAway doesn't warn that result might be null. This happens for the same reason as the last example:

<T> T invokeAny(Collection<? extends Callable<T>> tasks) throws InterruptedException, ExecutionException;

invokeAny has the generic method type <T> declared, but the annotation from @Nullable Throwable is lost when Collection<Callable<@Nullable Throwable>> is passed.

This gives a false negative, as we can accept any result from invokeAny (or any function with a generic typing) and it loses the @Nullable annotation.

Expected behaviour

The generic type from methods need to retain the @Nullable typing when given a @Nullable Object, otherwise NullAway might give false positives or even worse, false negatives.

Marinell0 avatar Oct 29 '25 21:10 Marinell0

Hi @Marinell0 thanks for the report. At least some of these issues are due to missing JDK models (#950), which we are actively working on and hope to have some solid progress soon. The false positive in your first example goes away if I write my own identity function:

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

import java.util.List;
import java.util.function.Function;

@NullMarked
class Scratch {

  static <T extends @Nullable Object> Function<T, T> identity() {
    return t -> t;
  }

  public interface NullAwayExample<T> {
    default void doSomething(List<@Nullable T> input) {
      doSomething(input, Scratch.identity());
    }

    <U> void doSomething(List<@Nullable U> input, Function<@Nullable U, @Nullable T> mapper);
  }
}

I'll investigate the false negative in your second example soon, but I also suspect missing library models to be the issue.

msridhar avatar Oct 30 '25 19:10 msridhar

Perfect, that's how I fixed my Function.identity() issue too, but it is good to have reassurance =)

Thanks for the quick response, I'll follow #950 in the meantime. As this issue is possibly because of it, you may close this as duplicate if you want.

Thanks!

Marinell0 avatar Oct 30 '25 19:10 Marinell0