Skip to content

#4123 Null check Optional source properties with NullValueCheckStrategy.ALWAYS - #4124

Open
velo wants to merge 1 commit into
mapstruct:mainfrom
velo:4123-optional-null-value-check-strategy
Open

velo wants to merge 1 commit into
mapstruct:mainfrom
velo:4123-optional-null-value-check-strategy

Conversation

@velo

@velo velo commented Sep 9, 2026

Copy link
Copy Markdown

Fixes #4123

Problem

For an Optional source property MapStruct generates a bare presence check:

if ( source.getName().isPresent() ) {
    target.setName( source.getName().get() );
}

If the Optional-typed property is itself null, that throws a NullPointerException. A null Optional field is easy to reach in practice: Lombok's @Builder leaves an unset Optional component as null, and Jackson leaves it null when the JSON property is absent.

The release notes state the missing null check is intentional, and this PR keeps that default. The actual defect is that nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS has no effect on Optional source properties: the generated method body is identical with and without it, which the committed fixture OptionalNullCheckAlwaysMapperImpl.java shows. ALWAYS is documented as "always include a null check when source is non primitive", so a source that can be null is exactly what it is for.

The reason is that an Optional source already forces includeSourceNullCheck on through the type conversion, so the ALWAYS branch in setterWrapperNeedsSourceNullCheck never changes the outcome, and the template renders the check as .isPresent().

Fix

PropertyMapping now builds an explicit presence check for an Optional source property when NullValueCheckStrategy.ALWAYS is configured, composing the existing NullPresenceCheck and OptionalPresenceCheck:

if ( source.getName() != null && source.getName().isPresent() ) {
    target.setName( source.getName().get() );
}

Scope

  • Only applies with nullValueCheckStrategy = ALWAYS. The default ON_IMPLICIT_CONVERSION is unchanged, so no existing user sees a different mapper.
  • Only applies to a direct (non-nested) source property, where the getter can safely be evaluated twice. Nested paths already go through a forged method and a local variable.
  • A JSpecify @NonNull source still skips the guard, consistent with JSpecify taking precedence over NullValueCheckStrategy elsewhere.
  • Not covered: an Optional source parameter (Target map(Optional<Source> src)) is still checked with a bare isPresent(), and Optional<Map> / Optional<Collection> source property generates unguarded Optional#get, throwing NoSuchElementException when empty #4111 (missing presence check for Optional<Map> / Optional<Collection>) is unrelated.

Tests

Extended org.mapstruct.ap.test.optional.nullcheckalways:

  • optionalToOptionalWhenNull / optionalToNonOptionalWhenNull — a null Optional property no longer throws and the target is left untouched.
  • Updated OptionalNullCheckAlwaysMapperImpl fixture to the guarded form.
  • New OptionalDefaultNullCheckMapper plus fixture, pinning the unchanged default-strategy output.

The existing empty/present cases and the other optional fixtures (default strategy) are unchanged.

mvn install on JDK 21: MapStruct Processor module green, 3649 tests, 0 failures. The integrationtest module was not run locally (it spawns nested Maven builds that need network access).

Documentation updated in chapter-3 (Optional section) and chapter-10 (NullValueCheckStrategy).

…eckStrategy.ALWAYS

Signed-off-by: Marvin Froeder <velo.br@gmail.com>
@velo

velo commented Sep 16, 2026 •

Copy link
Copy Markdown
Author

@ijioio your comments here and on #4123 were deleted, quoting from the notification emails:

ijioio on #4124: Please take patch semantics into account. I've described the problem in more detail here.

ijioio on #4123:

Do not add a check like this:

if (source.getName() != null && source.getName().isPresent()) {
    target.setName(source.getName().get());
}

This breaks patch semantics.

A patch needs to distinguish between three states:

  • null - ignore the field and keep its existing value
  • Optional.empty() - clear the field by setting it to null
  • Optional.of(value) - update the field with the wrapped value

Therefore, the correct behavior is to restore the original semantics of org.mapstruct.NullValuePropertyMappingStrategy.IGNORE. This is also explicitly stated in its Javadoc:

If a source bean property equals null the target bean property will be ignored and retain its existing value.

In other words, null must remain distinguishable from an empty Optional. The mapping logic should preserve that distinction rather than treating both cases as "do nothing."


Agreed on Optional → Optional: gating a same-type copy on isPresent() drops Optional.empty(). That is already on main under ALWAYS and this PR froze it into a fixture — I will change it to a != null check only, keeping != null && isPresent() only where the value is unwrapped.

The three-state patch behaviour itself is NullValuePropertyMappingStrategy on update methods, not NullValueCheckStrategy, so I would leave that to #4128. Fine with me if you would rather item 1 land there too.

@ijioio

ijioio commented Sep 16, 2026 •

Copy link
Copy Markdown

@velo Yes, I've deleted my previous comment here cause I've confused ALWAYS strategy with IGNORE strategy, sorry for that.

I'm still not sure whether the Optional unwrapping code can both preserve the three-state semantics and perform the unwrapping correctly. This is the approach I'm currently using for patch mappings (for MapStruct 1.6.3):

public class Source {
    private Optional<String> foo;
    public Optional<String> getFoo() { return foo; }
    public void setFoo(Optional<String> foo) { this.foo = foo; }
}

public class Target {
    private String foo;
    public String getFoo() { return foo; }
    public void setFoo(String foo) { this.foo = foo; }
}

@Mapper
public interface OptionalMapper {
    public static final OptionalMapper INSTANCE = Mappers.getMapper(OptionalMapper.class);
    public default <T> T extract(Optional<T> optional) {
        return optional.orElse(null);
    }
}

@Mapper(uses = OptionalMapper.class, nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
public interface PatchMapper {
    public static final PatchMapper INSTANCE = Mappers.getMapper(PatchMapper.class);
    public void patch(@MappingTarget Target target, Source source);
}

So, NullValuePropertyMappingStrategy.IGNORE is what handles the three-state semantics, while OptionalMapper is responsible for unwrapping the Optional value. I hope this behavior can be preserved

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.

Optional source property that is itself null throws NullPointerException on the generated isPresent() check; NullValueCheckStrategy.ALWAYS is ignored

2 participants