Skip to content

Constructor binding: absent vs null #6063

Open

Description

@joca-bt

I have a strict json mapper, forbidding all type coercions:

JsonMapper.builder()
    .withCoercionConfigDefaults(coercionConfig -> {
        coercionConfig.setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail)
            .setCoercion(CoercionInputShape.Integer, CoercionAction.Fail)
            .setCoercion(CoercionInputShape.Float, CoercionAction.Fail)
            .setCoercion(CoercionInputShape.String, CoercionAction.Fail)
            .setCoercion(CoercionInputShape.Array, CoercionAction.Fail)
            .setCoercion(CoercionInputShape.Object, CoercionAction.Fail);
    })
    .changeDefaultNullHandling(_ -> JsonSetter.Value.construct(Nulls.FAIL, Nulls.FAIL))
    .build()

And I would like to use my DTOs with constructors instead of setters:

public class TestClass {
    private String propertyA;
    private String propertyB;

    // This.
    public TestClass(String propertyA, String propertyB) {
        this.propertyA = propertyA;
        this.propertyB = propertyB;
    }

    // Over this.
    // public void setPropertyA(String propertyA) {
    //     this.propertyA = propertyA;
    // }

    public String getPropertyA() {
        return propertyA;
    }

    // public void setPropertyB(String propertyB) {
    //     this.propertyB = propertyB;
    // }

    public String getPropertyB() {
        return propertyB;
    }
}

If I do this:

jsonMapper.readValue("{ \"propertyA\": \"value\" }", TestClass.class);

I will obtain the following exception:

Invalid `null` value encountered for property "propertyB"
 at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); byte offset: #UNKNOWN] (through reference chain: TestClass["propertyB"])
tools.jackson.databind.exc.InvalidNullException: Invalid `null` value encountered for property "propertyB"
 at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); byte offset: #UNKNOWN] (through reference chain: TestClass["propertyB"])

I would expect this error to be thrown if I provided \"propertyB\": null but not in this situation.

I understand that this is caused by my null configuration, however, we need to keep it.

Is what I am trying to achieve possible out of the box? That is, not providing propertyB is acceptable while providing it with value null is not (absent is ok, explicit null is forbidden).

Originally reported in #3847 in a slightly different manner.

Versions used:

  • com.fasterxml.jackson.core:jackson-annotations:2.21
  • tools.jackson.core:jackson-core:3.1.4
  • tools.jackson.core:jackson-databind:3.1.4

Activity

  1. pjfanning commented on Jun 30, 2026

    @pjfanning
    Member

    @joca-bt does opening a new issue help? I think not. It splits the history. Closing this.

  2. joca-bt commented on Jun 30, 2026

    @joca-bt
    Author

    @pjfanning the other ticket was closed up without a solution and afaik you don't reopen issues.

    This is also for version 3 not 2.

  3. pjfanning commented on Jun 30, 2026

    @pjfanning
    Member

    @joca-bt Fair enough. Reopened. I'm using my mobile and didn't notice the old issue was closed.

  4. cowtowncoder commented on Jul 2, 2026

    @cowtowncoder
    Member

    Quick question/confirmation: does 3.2.0 behave the same?

    Other than that, this may be tricky one as absent value must somehow be mapped into constructor parameter -- it cannot be just omitted like setter call or field assignment can -- and for that, null coercion rules are to be applied.

    But you should be able to change handling of nulls for specific parameter with @JsonSetter I think; to override global settings.
    Maybe that is the work-around to use?

  5. joca-bt commented on Jul 2, 2026

    @joca-bt
    Author

    3.2.0 seems to behave the same way.

    I was able to find a workaround.

    I added a custom NullValueProvider which returns null for getAbsentValue instead of calling getNullValue (which throws an error for Nulls.FAIL). I registered it through a ValueDeserializerModifier.

    However, I wonder if this should be the default behaviour for NullsFailProvider and others.

    When using setters instead of constructor, getAbsentValue is skipped, and the property will get the default Java value (null for objects, something else for primitives). From this perspective, the behaviour is different between both usages (setters vs constructor). Should the behaviour be the same?

    My expectation for null coercion rules was that they would be applied only when null is actually provided by the client, which is not this case.

    I arrived at my workaround with some help from Claude, here is the session if you are interested, it has some interesting analysis.

    session-history.md

  6. cowtowncoder commented on Jul 2, 2026

    @cowtowncoder
    Member

    I think that many/most users want missing and explicit null both fail so changing default behavior is probably not possible or desired.
    But I would be open to new MapperFeature to allow opt-in change?

    On constructors vs setters, behavior difference... this is not the only difference so I think that while unifying behavior is a good goal, in practice documenting things is necessary.
    F.ex, required is only ever enforced for Constructor-passed properties.

  7. joca-bt commented on Jul 6, 2026

    @joca-bt
    Author

    An in-built feature would be great!

    I went with the workaround below (still needs to pass proper testing):

    public class NullValueProviderModule extends JacksonModule {
        @Override
        public String getModuleName() {
            return getClass().getName();
        }
    
        @Override
        public void setupModule(SetupContext context) {
            context.addDeserializerModifier(new NullValueProviderInjector());
        }
    
        @Override
        public Version version() {
            return new Version(1, 0, 0, null, null, null);
        }
    
        private static class NullValueProvider extends NullsFailProvider {
            private static final long serialVersionUID = 1L;
    
            public NullValueProvider(BeanProperty property) {
                super(property.getFullName(), property.getType());
            }
    
            @Override
            public Object getAbsentValue(DeserializationContext context) {
                return null;
            }
        }
    
        private static class NullValueProviderInjector extends ValueDeserializerModifier {
            private static final long serialVersionUID = 1L;
    
            @Override
            public BeanDeserializerBuilder updateBuilder(
                    DeserializationConfig config, BeanDescription.Supplier description, BeanDeserializerBuilder builder) {
                ValueInstantiator valueInstantiator = builder.getValueInstantiator();
    
                if (valueInstantiator.canCreateFromObjectWith()) {
                    SettableBeanProperty[] properties = valueInstantiator.getFromObjectArguments(config);
    
                    for (int i = 0; i < properties.length; i++) {
                        SettableBeanProperty property = properties[i];
                        properties[i] = property.withNullProvider(new NullValueProvider(property));
                    }
                }
    
                return builder;
            }
        }
    }
    
  8. joca-bt commented on Jul 21, 2026

    @joca-bt
    Author

    @cowtowncoder do you have an idea of the name for the feature? I could try working on an implementation if so.

  9. cowtowncoder commented on Jul 21, 2026

    @cowtowncoder
    Member

    @joca-bt No good name comes to mind immediately. You can use a placeholder for now... ABSENT_BEHAVES_LIKE_NULL?

    Also, depending on implementation, DeserializationFeature might work better than MapperFeature (since then it can be dynamically changed for ObjectReaders).

  10. joca-bt commented on Aug 10, 2026

    @joca-bt
    Author

    Sorry, I won't have time to pick this up anytime soon. What I was thinking was adding a new null provider and use it when the feature is on.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions