Add support for JSpecify nullness annotations (#1243)#4033
Add support for JSpecify nullness annotations (#1243)#4033filiphr merged 11 commits intomapstruct:mainfrom
Conversation
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
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.
|
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? |
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.
|
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() { }
} |
|
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 I'll create another issue for this, as that looks a bit more involved. |
|
@vmckinney-cainc I would argue that you are doing an incorrect search for JSpecify. Perhaps a search for However, I'm not the biggest user of JSpecify yet, but what I've seen in the ecosystem is the Having said that, I'm not sure that I would like to expand this support to all the other variations of |
|
First, thank you very much @filiphr for adding support for nullness annotations! This has been a long-expected feature that I’m happy to see. I would like to support @vmckinney-cainc's feedback on considering additional nullness annotations (other than JSpecify, such as We have just tried out switching our codebase to the SNAPSHOT version of MapStruct built from this branch and seen new errors where source properties are annotated with non‑JSpecify
Because MapStruct currently only recognizes JSpecify annotations, those sources are treated as potentially nullable. For us, this results in many occurrences across mappers and would require adding defaultValue/defaultExpression for every affected property, even though the sources are annotated by their authors. While I agree that JSpecify is becoming the industry standard, certain libraries may choose to not migrate to it from other annotation types. Adding support for non-JSpecify annotations would reduce the amount of errors an user of the MapStruct library would need to solve when transitioning to the new MapStruct release with improved nullness support. Therefore, I would like to propose introducing an annotation processor option (e.g. This could improve the migration path for projects such as mine that depend on libraries unlikely to adopt JSpecify soon. If this direction sounds reasonable, we could also discuss adding configurable support for variations of Thanks again for your time and for maintaining MapStruct. |
As lombok is a generation tool, isn't it supposed to generate the JSpecify annotation based on its annotations ? |
Apologies! Didn't mean to misrepresent the popularity, I'm just not familiar with JSpecify.
I can say at my company in my little niche that in our corporate git we have 1 single match for
With |
Caveat with regards to Lombok: |
JSpecify is not just yet another nonNull annotation specification. It's a consortium with a lot of major Java actor https://jspecify.dev/about/ |
…ll check A nested source chain may yield null through any intermediate accessor, so the chain is only effectively @nonnull when every accessor along it is @nonnull. Previously only the deepest accessor was consulted, which caused setter-side null checks to be dropped for chains like nullableAddress.street where the intermediate was @nullable and the deepest was @nonnull, passing null into a @nonnull target setter.
Converts the static NullabilityUtils helper into an instance-scoped NullabilityResolver (mirroring AccessorNamingUtils) carrying a boolean enabled flag derived from the new mapstruct.disableJSpecify processor option. When the option is set, every JSpecify-driven behavior — property-level null-check inference, method-level guard skipping, @NullMarked scope resolution and the @nonnull constructor-parameter hard error — is suppressed and code generation falls back to the pre-JSpecify NullValueCheckStrategy-driven behavior.
|
Thanks @pkernevez for the clarification around JSpecify. JSpecify is indeed something that took a really long time to get to, and I think the reason has been that there have been all those big players with the different existing annotations. I think with JSpecify 1.0.0 out there, it would become the standard. Considering the fact that large open source projects like Spring and Hibernate have already adopted them, I think it's a matter of fact until other libraries follow suit. IntelliJ also supports them, Kotlin also supports them for their interoperability for reading Java code from Kotlin. They have KT-47417 open for the other way arround, i.e. emit JSpecify annotation from Kotlin itself. @vmckinney-cainc, I'm not sure which other dependencies you use, but I would be surprised if you do not have jspecify on the transitive path if you are on the latest versions of your dependencies. @rRgbMVg7tO thanks for your comment.
This was a trigger for me to actually add an opt-out for this using a compiler flag
To be honest I would rather spend my time in helping those libraries get to JSpecify then spend the time on supporting the different flavors and quirks of the other nullability annoations.
That's the pandora's box I do not want to open. @vmckinney-cainc not sure what you mean with
If you are thinking that it would be hard for us to read Thanks everyone for your feedback. It is really valuable. However, my decision for this is final. I really do not want to spend time on supporting that the major Java actors have converged on. I would merge this PR in this stance and I would try to do a 1.7.0.Beta2 release soonish so that the community can already start trying it out. |
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,@NullMarkedand@NullUnmarkedfrom org.jspecify.annotations to control null check generation in mapping code.Property-level null checks:
@NonNull: skip null check (value guaranteed non-null)@NonNull: always add null check (must protect non-null target)NullValueCheckStrategyMethod-level source parameter:
@NonNullparameter: skip the method-level null guard@NullMarked/@NullUnmarkedscope:@NullMarkedclass or package, unannotated types are effectively@NonNull@NullUnmarkedreverts to unknown nullability within its scope@Nullableon a specific type overrides the scopeConstructor parameters:
Fixes Support for JSpecify nullness annotations #1243
Would like to get the feedback from the community on this one.
Some open things that could go in followup PRs: