Skip to content

Add support for JSpecify nullness annotations (#1243)#4033

Open
filiphr wants to merge 8 commits intomapstruct:mainfrom
filiphr:jspecify-nullness-support
Open

Add support for JSpecify nullness annotations (#1243)#4033
filiphr wants to merge 8 commits intomapstruct:mainfrom
filiphr:jspecify-nullness-support

Conversation

@filiphr
Copy link
Copy Markdown
Member

@filiphr filiphr commented Apr 12, 2026

Long overdue, I finally got the time to dig into support for NonNull / Nullable. I've decided to only support JSpecify for now, since it seems the ecosystem has converged on that.


Detect @NonNull, @Nullable, @NullMarked and @NullUnmarked from org.jspecify.annotations to control null check generation in mapping code.

Property-level null checks:

  • Source @NonNull: skip null check (value guaranteed non-null)
  • Target @NonNull: always add null check (must protect non-null target)
  • All other cases: defer to existing NullValueCheckStrategy
  • Safety guards (default values, unboxing, NVPMS) are preserved

Method-level source parameter:

  • @NonNull parameter: skip the method-level null guard

@NullMarked / @NullUnmarked scope:

  • In a @NullMarked class or package, unannotated types are effectively @NonNull
  • @NullUnmarked reverts to unknown nullability within its scope
  • @Nullable on a specific type overrides the scope
  • Scope is resolved by walking the enclosing element chain (method -> class -> outer class -> package) and the result is cached on Type.isNullMarked()

Constructor parameters:


Would like to get the feedback from the community on this one.


Some open things that could go in followup PRs:

  • Tests with Kotlin nullable / non-null types

Detect @nonnull, @nullable, @NullMarked and @NullUnmarked from
org.jspecify.annotations to control null check generation in
mapping code.

Property-level null checks:
- Source @nonnull: skip null check (value guaranteed non-null)
- Target @nonnull: always add null check (must protect non-null target)
- All other cases: defer to existing NullValueCheckStrategy
- Safety guards (default values, unboxing, NVPMS) are preserved

Method-level source parameter:
- @nonnull parameter: skip the method-level null guard

@NullMarked / @NullUnmarked scope:
- In a @NullMarked class or package, unannotated types are effectively
  @nonnull
- @NullUnmarked reverts to unknown nullability within its scope
- @nullable on a specific type overrides the scope
- Scope is resolved by walking the enclosing element chain
  (method -> class -> outer class -> package) and the result is
  cached on Type.isNullMarked()

Constructor parameters:
- Report a compile error when mapping a potentially nullable source to
  a @nonnull constructor parameter without a defaultValue, since a null
  check would leave the variable at null violating the contract
filiphr added 5 commits April 13, 2026 09:43
Address two correctness issues in the JSpecify support added in mapstruct#1243:

- Direct parameter mappings (no property entries) now consult the source
  parameter's own nullability when checking against a @nonnull constructor
  parameter, eliminating a false-positive "potentially nullable source" error
  when a @nonnull method parameter is wired directly into a @nonnull
  constructor argument.
- Target-side @NullMarked scope resolution now uses the bean that declares
  the setter/field instead of the property value type, so unannotated setter
  parameters inside a @NullMarked bean are correctly promoted to @nonnull.

Add tests covering direct-parameter constructor mappings (positive and
erroneous), @NullUnmarked scope override, explicit @nullable overriding an
enclosing @NullMarked scope, a @NullMarked target bean's unannotated setter,
defaultValue suppressing the constructor-parameter error, behavioral
assertion for the skipped method-level guard on a @nonnull parameter, and
preservation of the unboxing, defaultValue, and SET_TO_DEFAULT (update
method) safety guards for @nullable sources.
Address several follow-ups from the review of mapstruct#1243:

- Honor element-level @NullMarked / @NullUnmarked by walking the element's
  own enclosing chain up to the declaring type before consulting the
  bean-type scope. A @NullUnmarked method inside a @NullMarked class now
  correctly reverts unannotated types back to unknown nullability.
- Guard unchecked (TypeElement) casts in NullabilityUtils and Type against
  annotation elements that are not TypeElements (e.g. ErrorType during
  incremental builds), which would otherwise surface as a processor crash.
- Tighten getSetterNullability: a zero-parameter ExecutableElement is not a
  valid write accessor, so return UNKNOWN instead of silently falling
  through to the return-type path.
- Emit verbose Messager notes at every JSpecify-driven null-check decision
  (setter skip, setter add, method-level guard skip) so the behavior is
  visible under -Amapstruct.verbose=true rather than silent.

Also expand Javadoc on JSpecifyConstants fields, Type.isNullMarked (scope
walk + cache semantics), and NullabilityUtils.getNullability /
getSetterNullability (supplier contract, field + zero-arg handling), and
align the constructor-parameter error comment with its actual condition
(fires on any source not guaranteed @nonnull, not only explicit @nullable).
When the mapping resolver could not find an assignment (e.g. incompatible
source/target types), the JSpecify @nonnull constructor-param check still
fired, producing a misleading duplicate error on top of the primary
"can't find mapping" diagnostic. Guard the check on assignment != null so
users see the root-cause error alone.
Split the single PROPERTYMAPPING_JSPECIFY_NOTE template into dedicated
Message entries so the English phrasing lives in the enum rather than
being passed as format arguments from every call site.

Also correct Javadoc that overstated behavior:
- Type#isNullMarked: module support is conditional on JPMS, and TypeFactory
  does not intern Type instances, so the "O(1)" / "interned" claim was
  misleading. Reword to memoization + caller guidance.
- JSpecifyConstants: make NULL_MARKED_FQN / NULL_UNMARKED_FQN wording
  symmetric about closest-annotation precedence.
- PropertyMapping constructor-param check: clarify that the diagnostic is
  a hard compile error, not a passive note.

Loosen JSpecifyVerboseNoteTest regex to match the nullability enum values
positionally rather than pinning exact constant names.
@nealeu
Copy link
Copy Markdown

nealeu commented Apr 13, 2026

This looks great @filiphr. Given the JDK 21 failure, are you going to add a Java 25 (26 too - i.e. last non-LTS) into the CI matrix?

@filiphr
Copy link
Copy Markdown
Member Author

filiphr commented Apr 13, 2026

Given the JDK 21 failure, are you going to add a Java 25 (26 too - i.e. last non-LTS) into the CI matrix?

It's a silly error in the test code. Will be fixed. I'll add Java 25 and 26 separately into the CI matrix, it's not linked to this issue in particular.

The test passed "withoutpackageinfo" (lowercase) as the variant but the
fixture file on disk is PackageNullMarkedMapperImpl_withoutPackageInfo.java
(PascalCase). macOS's case-insensitive filesystem matched them locally;
Linux CI (case-sensitive) could not find the fixture and failed.
@nealeu
Copy link
Copy Markdown

nealeu commented Apr 13, 2026

I don't want to delay you merging what you have because it's already really valuable and will make a big difference for us.

However, I'm not clear if this does or doesn't cover type use on array elements. Some permutations of copies between the arrays below should fail.

@NullMarked
class JSpecifyArrays {

  public String @Nullable[] getNullableArrayOfNonNullStrings() { }

  public @Nullable String[] getNonNullArrayOfNullableStrings() { }

  public String[] getNonNullArrayOfNonNullStrings() { }
}

@filiphr
Copy link
Copy Markdown
Member Author

filiphr commented Apr 13, 2026

Thanks for looking into this @nealeu. I think that second 2 should be covered

  public @Nullable String[] getNonNullArrayOfNullableStrings() { }

  public String[] getNonNullArrayOfNonNullStrings() { }

However, I think that the first one would be treated the same as the getNonNullArrayOfNonNullStrings.

I'll create another issue for this, as that looks a bit more involved.

Adds a section to the advanced mapping options chapter describing how
@nonnull, @nullable, @NullMarked, and @NullUnmarked influence null-check
generation, scope resolution, the method-level guard skip for @nonnull
parameters, and the constructor-parameter compile error.
@vmckinney-cainc
Copy link
Copy Markdown

vmckinney-cainc commented Apr 14, 2026

I would humbly ask that you also consider @lombok.NonNull, which is anecdotally very common as well (e.g. 272k github results for import lombok.NonNull vs. 58k for import org.jspecify.annotations.NonNull). Thank you so much for your hard work, I am a big fan of your project!

@filiphr
Copy link
Copy Markdown
Member Author

filiphr commented Apr 14, 2026

@vmckinney-cainc I would argue that you are doing an incorrect search for JSpecify. Perhaps a search for import org.jspecify.annotations with 260k results is more relevant. Especially considering the fact that maybe the most common use of JSpecify I would expect is the use of @NullMarked or @NullUmarked on package-info.java which would be applied to all classes in that package.

However, I'm not the biggest user of JSpecify yet, but what I've seen in the ecosystem is the package-info.java usage.

Having said that, I'm not sure that I would like to expand this support to all the other variations of Nullable / NonNull, especially due to the fact that it looks like JSpecify is becoming the industry standard for this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support for NonNull / Nullable annotations

3 participants