Optional source property that is itself null throws NullPointerException on the generated isPresent() check; NullValueCheckStrategy.ALWAYS is ignored
Expected behavior
When a source property is Optional<T>, the generated presence check should tolerate the Optional reference itself being null, e.g.:
if ( source.getName() != null && source.getName().isPresent() ) {
name = source.getName().get();
}or, at minimum, nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS should produce that guard, since that is the documented knob for "always null-check the source before using it".
Actual behavior
The generated code calls isPresent() on the raw getter result, with no null check:
@Override
public Target toTarget(Source source) {
if ( source == null ) {
return null;
}
String name = null;
if ( source.getName().isPresent() ) {
name = source.getName().get();
}
Target target = new Target( name );
return target;
}If the Optional property is null (which is a plain reference like any other, and is what many object builders and deserializers leave it as), this throws:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.util.Optional.isPresent()" because the return value of "repro.Source.getName()" is null
at repro.SourceMapperImpl.toTarget(SourceMapperImpl.java:20)
at Demo.main(Demo.java:8)The 1.7.0.Beta1 release notes say this is intentional: "Note that we are not doing any null checks for the optional properties. Instead, we do a check if the optional is present or not and map it." The problem is that this makes the generated mapper unsafe for any source object that can hold a null Optional, and there is no configuration to opt out of it.
NullValueCheckStrategy.ALWAYS has no effect here. With the identical model and
@Mapper(nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS)
public interface SourceMapperAlways
{
Target toTarget( Source source );
}the generated body is byte-for-byte the same as with the default strategy — still a bare source.getName().isPresent(). That seems like the core issue: the setting that exists precisely to force source null checks is silently ignored for Optional properties.
This is not a hypothetical input. Two common cases where a null Optional source field arises:
- Generated builders. Lombok's
@Builderleaves an unsetOptionalcomponent asnull, notOptional.empty()(verified below with Lombok 1.18.48). - Jackson deserialization, where
null(field absent from the JSON) andOptional.empty()(field explicitlynullin the JSON) are deliberately distinguished — this exact scenario was raised in https://github.com/mapstruct/mapstruct/issues/674#issuecomment-4291090849 but there is no tracking issue for it, hence this report.
This is distinct from #4111, which is about the presence check being omitted entirely for Optional<Map> / Optional<Collection>; here the presence check is generated, but it is itself unguarded.
Workaround (works, but requires one @Condition method per mapper/type):
@Condition
default boolean isSet( Optional<String> value )
{
return value != null && value.isPresent();
}which produces if ( isSet( source.getName() ) ).
Steps to reproduce the problem
Minimal case, no Lombok, plain javac, no other annotation processors.
Source.java:
package repro;
import java.util.Optional;
public class Source
{
private Optional<String> name;
public Optional<String> getName()
{
return name;
}
public void setName( Optional<String> name )
{
this.name = name;
}
}Target.java:
package repro;
public record Target( String name )
{ }SourceMapper.java:
package repro;
import org.mapstruct.Mapper;
@Mapper
public interface SourceMapper
{
Target toTarget( Source source );
}Demo.java:
import repro.*;
public class Demo
{
public static void main( String[] args )
{
Source source = new Source();
System.out.println( new SourceMapperImpl().toTarget( source ) );
}
}javac -cp mapstruct-1.7.0.Beta2.jar \
-processorpath mapstruct-processor-1.7.0.Beta2.jar:mapstruct-1.7.0.Beta2.jar \
-s gen -d out src/repro/*.java src/Demo.javaAdding a second mapper with nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS over the same types generates an identical method body.
Lombok variant, showing that this is reachable without ever writing null by hand:
package repro;
import java.util.Optional;
import lombok.Builder;
@Builder
public record Source( String id, Optional<String> name )
{ }Source source = Source.builder().id( "1" ).build();
System.out.println( "name field is: " + source.name() ); // => name field is: null
new SourceMapperImpl().toTarget( source ); // => NPEname field is: null
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.util.Optional.isPresent()" because the return value of "repro.Source.name()" is null
at repro.SourceMapperImpl.toTarget(SourceMapperImpl.java:22)
at Demo.main(Demo.java:9)MapStruct Version
1.7.0.Beta2, Java 25.0.2 (Eclipse Adoptium), javac. Lombok 1.18.48 for the second variant.
Source: mapstruct/mapstruct