Skip to content

Add nullability to MapStruct model - #4131

Open
hduelme wants to merge 135 commits into
mapstruct:mainfrom
hduelme:add-nullability-to-model
Open

hduelme wants to merge 135 commits into
mapstruct:mainfrom
hduelme:add-nullability-to-model

Conversation

@hduelme

@hduelme hduelme commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Implementation

The PR adds nullability as a state of type instances. It represents whether a return type, parameter or field can be null or whether null can be considered as a value.
How nullability is used by MapStruct:

  1. The new NullabilityResolver is made accessible in DefaultModelElementProcessorContext for all processors. The NullabilityResolver is the main point for determining nullability.
  2. The parameter and return type nullability of the SourceMethods is discovered and stored in MethodRetrievalProcessor.
  3. MapperCreationProcessor creates the mappings according to the nullability values discovered in step 2.
  4. Add the JSpecify annotations if available with the NullabilityAnnotationProcessor. I have decided to always add them if available, to ensure the highest possible compatibility with the checking frameworks. The way they are added is a reverse resolve approach (resolve outside-in). First the nullability scope of the target package is determined. Annotations from the base mapper are then inherited if they do not match the outer nullability scope. Finally @Nullable/@NonNull is added if necessary in its scope. This closes Decide whether or not we should mark generated code with JSpecify annotations #4036 and JSpecify: Propagate nullability annotations to mapper implementation #4076. This is an alternative solution to #4076 Propagate JSpecify nullability annotations to the mapper implementation #4112

In order to match the specified nullability requirements as often as possible, the built-in methods were modified so that they no longer accept and return null as a value.The exceptions are the following, as they can also return null under other conditions:

  • JaxbElemToValue
  • XmlGregorianCalendarToJodaDateTime
  • XmlGregorianCalendarToJodaLocalDate
  • XmlGregorianCalendarToJodaLocalDateTime
  • XmlGregorianCalendarToJodaLocalTime
  • XmlGregorianCalendarToLocalDateTime

In addition the new strict matching of nullability requirements causes some changes:

As mentioned some changes are breaking and should be discussed or at least mentioned in the release notes.

Testing

Some tests are worth mentioning.

For built-in methods I added JspecifyBuildInTest. Here I wanted three types of tests for each conversion. One where the source values are @NonNull, one where the target values are @NonNull and one where both are @NonNull. Instead of tripling the test classes, I decided to move them into a package and use @WithPackageInfo to mark them as nullable or non nullable. While testing this I discovered #4132. That's why I only added fixture comparison tests.

@NullMarked can also be applied at module scope, instead of marking the whole processor tests with @NullMarked I decided to create a dedicated integration test for it.

JSpecifyContainerPropertyTest now checks if collections/maps/streams are null-checked if needed. Thanks to @zanarellidev for finding this. Alternative solution to #4103

In the integration test I found two JDK8 bugs resulting in the exclusion of some JSpecify tests from fullFeatureTest()

DestinationClassNameTest/DestinationPackageNameTest now always run with mapstruct.disableJSpecify. The problem is that eclipse 1.6 has an issue when resolving a non-existing package and creating it afterward.

Other

I left all commits in here for now. If I should squash them, I will do so.

@hduelme
hduelme marked this pull request as draft September 19, 2026 16:13
@hduelme
hduelme requested a review from filiphr September 19, 2026 22:43
@hduelme hduelme changed the title Add nullability to model Add nullability to MapStruct model Sep 19, 2026
@hduelme
hduelme marked this pull request as ready for review September 19, 2026 22:43

@filiphr filiphr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for this @hduelme, this is a big piece of work and moving nullability into the model is the right direction. The scope handling and the test matrix for @NullMarked / @NullUnmarked look really good.

The main thing I'd like us to align on before going into details: users who do not use JSpecify should get the same generated code as today, unless the change is clearly intended and documented. At the moment a few changes apply to everyone, because unknown nullability (no annotations) is treated as "nullable" and then acted on. I've left inline comments for the concrete cases. Most of them would go away if we only act when the nullability is actually known (JSpecify or hardcoded), and keep the current behaviour when it is unknown.

A few of the inline comments are compile errors or NPEs in generated code for plain mappers, I've added small reproducers for each.

Regarding emitting @NonNull / @Nullable into the generated code when JSpecify is on the classpath: I'm fine with that being the default, but we need to mention it in the release notes and update the docs for mapstruct.disableJSpecify (see comment below).

All reproducers in the inline comments are mappers without any JSpecify annotations, compiled with the processor built from this PR and from main (211b2be).

This review was done with the help of Claude Code.

}

@Override
public boolean needsParameterNullCheck() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes every conversion require a null check when the source nullability is unknown, which means that for users without JSpecify null elements are now silently dropped from iterables, streams and maps.

public interface Demo {
    ListsTgt map(Lists l); // List<Integer> -> List<String>, Map<Integer, Integer> -> Map<String, String>
}

main:

list1.add( String.valueOf( integer ) );
...
String key = String.valueOf( entry.getKey() );
String value = String.valueOf( entry.getValue() );
map1.put( key, value );

This PR:

if ( integer != null ) {
    list1.add( String.valueOf( integer ) );
}
...
if ( entry.getKey() != null ) {
    String key = String.valueOf( entry.getKey() );
    if ( entry.getValue() != null ) {
        String value = String.valueOf( entry.getValue() );
        map1.put( key, value );
    }
}

So the list size changes and a map entry with a valid key and a null value disappears. There is no warning either.

I think the element null check should rely on the nullability: only filter when we know the element can't be passed (e.g. the target / parameter is JSpecify @NonNull), and keep the current behaviour when the nullability is unknown. Then the TODOs in IterableMappingMethod.ftl, MapMappingMethod.ftl and StreamMappingMethod.ftl can be resolved as well. For the map case I'd also not drop the entry when only the value is null.

methodRefX.setAssignment( attempt.sourceRHS );
conversionRefY.assignment.setAssignment( methodRefX );
result = conversionRefY.assignment;
if ( methodRefX.getSourceNullability().isNullable() ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

methodRefX.getSourceNullability().isNullable() is true for every user method without JSpecify, so every method -> conversion chain now goes through a NullSafe2StepMappingMethod. This causes a couple of problems for plain mappers:

Overloaded methods produce code that does not compile

@Mapper
public interface Overload {
    default Integer map(A a) { return 1; }
    default Integer map(B b) { return 2; }
    HolderT map(Holder h); // Holder { A a; B b; } -> HolderT { String a; String b; }
}
OverloadImpl.java:21: error: incompatible types: B cannot be converted to A
        holderT.setB( mapToString( h.getB() ) );

Both helpers are named mapToString and SupportingMappingMethod#equals only compares the name, so one of them is dropped. The same happens when the same user method is used with two different numberFormat / dateFormat values, in which case it compiles but one property uses the format of the other one.

@Context parameters are lost

@Mapper
public interface Ctx {
    default Integer toInt(A a, @Context StringBuilder log) { return 1; }
    HolderT map(Holder h, @Context StringBuilder log);
}
CtxImpl.java:26: error: cannot find symbol
        Integer integer = toInt( a, log );
  symbol:   variable log

Local variable can clash with the parameter name, as the variable is named decapitalize(sourceType.getName()) and existingVariableNames does not contain the parameter name (e.g. Calendar toCalendar(MethodBuiltInInput methodBuiltInInput)).

Primitive targets now get 0 / false, see the comment on NullSafe2StepMappingMethod.ftl.

Suggestion: only create the helper when we know that X can return null (JSpecify @Nullable) and Y does not accept null, otherwise keep the inline chain as on main. The helper name needs to be unique (reserved names / existing methods) and NullSafe2StepMappingMethod should have a proper equals / hashCode, and it needs to keep all parameters of X (context, target type, etc.).

private <@includeModel object=returnType.typeBound typeAnnotation=typeAnnotation> </@includeModel> ${name}(<#list parameters as param><@includeModel object=param/><#if param_has_next>, </#if></#list>)<@throws/> {
<#if first.needsParameterNullCheck()>
if ( ${first.sourceReference} == null ) {
return ${returnType.null};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For primitive targets this returns the default value, which changes the behaviour for plain mappers:

@Named("toInt")
default Integer toInt(String s) { return s == null ? null : Integer.valueOf( s ); }

@Mapping(target = "number", source = "name", qualifiedByName = "toInt") // long number
void update(Src src, @MappingTarget Tgt tgt);

main:

if ( src.getName() != null ) {
    tgt.setNumber( toInt( src.getName() ).longValue() );
}

This PR:

tgt.setNumber( toIntTolong( src.getName() ) );

private long toIntTolong(String s) {
    Integer integer = toInt( s );
    if (integer == null) {
        return 0L;
    }
    return integer.longValue();
}
case main PR
name == null, update method setter not called, existing value kept overwritten with 0
name != null, toInt returns null NPE 0 set silently

Apart from gating this on known nullability (previous comment), I think the helper should return the boxed type and let the existing setter null handling (NullValuePropertyMappingStrategy, etc.) decide what to do, instead of picking a default value.

return forgeMapping(
sourceRHS,
sourceType,
TypeInstance.of( sourceType, Nullability.hardcodedNullability( Nullability.NullabilityState.NON_NULL ) ),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing the null guard from the forged subclass method is fine, as all callers from the subclass mapping guarantee a non null value. However, Parameter#equals (and therefore ForgedMethod / MappingMethod equality) does not consider the nullability. AbstractBaseBuilder#createForgedAssignment (line 125) then reuses an existing method without the guard for a caller that can pass null, and the caller does not add a check either.

@Mapper
public interface Reuse {
    @BeanMapping(subclassExhaustiveStrategy = SubclassExhaustiveStrategy.RUNTIME_EXCEPTION)
    @SubclassMapping(source = Source1.class, target = Target1.class)
    AbstractTarget map(AbstractSource s);

    Holder map(HolderSource h); // HolderSource { Source1 child; } -> Holder { Target1 child; }
}

This PR:

holder.setChild( source1ToTarget1( h.getChild() ) );

protected Target1 source1ToTarget1(Source1 source1) {

    Target1 target1 = new Target1();

    target1.setName( source1.getName() );
    ...

map(new HolderSource()) throws an NPE, on main the if ( source1 == null ) return null; is there. The same can happen with JSpecify when a @NonNull Foo and a @Nullable Foo property map to the same type: whichever comes first decides whether the shared method has a guard.

I think the nullability of the parameters (and the return type) needs to be part of the identity when reusing forged methods.

<#else>
for ( <@includeModel object=sourceElementType/> ${loopVariableName} : ${sourceParameter.name} ) {
<@includeModel object=elementAssignment targetBeanName=resultName targetWriteAccessorName="add" targetType=resultElementType/>
<@lib.handleVariableNullCheck elementAssignment.needsParameterNullCheck() loopVariableName>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The built-in methods no longer do the null check themselves, but the array branch above (line 67) does not use handleVariableNullCheck like the collection branch here:

@Mapper
public interface Arr {
    ZonedDateTime[] map(Calendar[] in);
}
for ( Calendar calendar : in ) {
    zonedDateTimeTmp[i] = calendarToZonedDateTime( calendar );
    i++;
}
...
private ZonedDateTime calendarToZonedDateTime(Calendar cal) {
    return ZonedDateTime.ofInstant( cal.toInstant(), cal.getTimeZone().toZoneId() );
}

A null element now throws an NPE, on main it resulted in a null slot. It would be good to check the other call sites of built-in methods for the same thing, and to add runtime tests with null values for the built-ins (not only fixtures).

if ( kind.isPrimitive() ) {
return JSpecifyNullability.UNKNOWN;
}
if ( kind == TypeKind.ARRAY ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With JSpecify, @Nullable String[] means that the elements are nullable, and String @Nullable [] means that the array itself is nullable. Recursing into the component type reads the element nullability as the nullability of the array, so in a @NullMarked scope String @Nullable [] getValues() is treated as non null and the null check is skipped.

The same applies to Type.ftl line 17, which generates @NonNull String[] (annotating the elements) instead of String @NonNull []. JSpecifyNonNullReturnArrayMapper uses @Nullable NullMarkedSourceBean[] for a nullable array, so the test would need to be adjusted as well. It would be good to have tests for both forms.

annotations.addAll( additionalAnnotationsBuilder.getProcessedAnnotations( method.getExecutable() ) );
}
boolean defaultTargetRequired = isDefaultTargetRequired( sourceParameter, mappingEntries );
if ( defaultTargetRequired && sourceParameterNullability.isNonNullable()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this check method.getReturnTypeNullability().isNonNullable() instead of sourceParameterNullability.isNonNullable()? The message says that the return type is @NonNull, and the check at line 429 uses the return type nullability. With the current check a @Nullable source with a @NonNull return type and <ANY_REMAINING> / <ANY_UNMAPPED> mapped to <NULL> is not reported, and the error is reported for a nullable return type when the source is @NonNull.

Comment on lines +223 to +225
VALUEMAPPING_JSPECIFY_DEFAULT_RETURN_NULLABILITY("JSpecify value mapping method has a @NonNull return type but the implementation can return null. Either define a default value for null with source = \"NULL\" or implement a custom EnumMappingStrategy."),
VALUEMAPPING_JSPECIFY_SOURCE_MAPPED_TO_NULL("JSpecify value mapping method has a @NonNull return type but source =\"%s\" mapped to target = \"NULL\"."),
VALUEMAPPING_JSPECIFY_NULL_SOURCE_UNREACHABLE("Source parameter can't be null so source = \"NULL\" is ignored.", Diagnostic.Kind.WARNING ),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The messages use source = "NULL", but the constant is MappingConstants.NULL ("<NULL>"). Someone copying the message would end up mapping an enum constant named NULL. Small things as well: source =\"%s\" is missing a space, and the message on line 225 could mention JSpecify / @NonNull like the others. Something like:

  • 223: JSpecify: value mapping method has a @NonNull return type, but a null source would be mapped to null. Define @ValueMapping(source = MappingConstants.NULL, target = ...) or implement a custom EnumMappingStrategy.
  • 224: JSpecify: value mapping method has a @NonNull return type, but source = "%s" is mapped to target = "<NULL>".
  • 225: Source parameter is @NonNull, so @ValueMapping(source = "<NULL>") can never be applied and is ignored.

}
}
Nullability returnTypeNullability = mappingMethod.getReturnTypeNullability();
if ( scopeForTypes.needsAnnotation( returnTypeNullability ) ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When JSpecify is on the classpath (which can happen transitively through Guava, Spring 7, etc.), mappers that don't use JSpecify at all now get @NonNull on built-in helper parameters, forged subclass parameters and RETURN_DEFAULT methods, e.g.

protected Target1 source1ToTarget1(@NonNull Source1 source1)
private Calendar zonedDateTimeToCalendar(@NonNull ZonedDateTime dateTime)

I'm fine with this being the default, but we need to:

  • mention it in the release notes
  • update the docs for mapstruct.disableJSpecify (chapter-2-set-up.asciidoc and chapter-10-advanced-mapping-options.asciidoc), they currently say that disabling falls back to the pre JSpecify behaviour. That should mention the generated annotations, and it should stay true for the other changes (which is another reason to gate them on known nullability)
  • add a test for it. The jspecify jar is excluded from the processor test classpath, so nothing covers a plain mapper with JSpecify available, and the same mapper with mapstruct.disableJSpecify=true

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.

Decide whether or not we should mark generated code with JSpecify annotations

3 participants