Skip to content

Combination of sealed class and polymorphism does not work as expected when deserializing json #5496

@FranchescaMullin

Description

@FranchescaMullin

Search before asking

  • I searched in the issues and found nothing similar.

Describe the bug

Hi, we really like this library, and have been pushing it to it's limits recently. Perhaps I am doing something wrong, but I believe I have found a corner case that isn't quite covered by the type deduction.

I have a sealed class which permits both a parent class and a child class. If the json should deserialize to the child class then things work as expected. If the json should deserialize to the parent class then it cannot decide between the two. The child class contains one extra field in addition to the ones it inherits from the parent.

Error:

com.fasterxml.jackson.databind.exc.InvalidTypeIdException: Could not resolve subtype of [simple type, class com.demo.app.example.MyParentClass]: Cannot deduce unique subtype of com.demo.app.example.MyParentClass (2 candidates match)
at [Source: REDACTED (StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION disabled); line: 1, column: 46]

I am using type deduction.

Interestingly, if I remove the parent child relationship from the 2 classes permitted by the sealed class while keeping the json fields the same, then it works fine. Ditto if the parent and child are no longer inside a sealed class. That's what leads me to believe it may be something small missing in the implementation.

Version Information

        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.19.2</version>
        </dependency>

Reproduction

Snippet to run the test:

String JsonChild = "{\"myString\":\"my child string\",\"mySize\":1000.0,\"startDate\":\"2025-07-01\"}";
        String JsonParent = "{\"myString\":\"my child string\",\"mySize\":1000.0}";

        try {
            ObjectMapper mapper = new ObjectMapper();
            MyChildClass objMyChild = mapper.readerFor(MyChildClass.class).readValue(JsonChild);

            MySealedClass obj = mapper.readerFor(MySealedClass.class).readValue(JsonChild);
            switch(obj){
                case MyChildClass x ->{
                    log.info("deserialized to child class");
                }
                case MyParentClass x ->{
                    log.info("deserialized to parent class");
                }
            }

 // This already fails on the line below
            MyParentClass objMyParent= mapper.readerFor(MyParentClass.class).readValue(JsonParent);

            MySealedClass obj2 = mapper.readerFor(MySealedClass.class).readValue(JsonParent);
            switch(obj2){
                case MyChildClass x ->{
                    log.info("deserialized to child class");
                }
                case MyParentClass x ->{
                    log.info("deserialized to parent class");
                }
            }

        } catch (Exception e) {
            log.info(String.valueOf(e));
        }

Sealed Class

package com.demo.app.example;

import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonSubTypes.Type;
import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;

@JsonTypeInfo(
        use = Id.DEDUCTION
)
@JsonSubTypes({ @Type(MyParentClass.class), @Type(MyChildClass.class)})
public sealed interface MySealedClass permits MyParentClass, MyChildClass {
}

Parent Class

package com.demo.app.example;


import com.fasterxml.jackson.annotation.*;
import java.math.BigDecimal;
import java.util.Objects;

import org.springframework.lang.Nullable;

public non-sealed class MyParentClass implements MySealedClass{
    public static String tag = "data::example::MyParentClass";

    private String myString;

    private BigDecimal mySize;


    public String getMyString() {
        return this.myString;
    }

    public void setMyString(String myString) {
        this.myString = myString;
    }

    public BigDecimal getMySize() {
        return this.mySize;
    }

    public void setMySize(BigDecimal mySize) {
        this.mySize = mySize;
    }


    public MyParentClass() {
    }

    public MyParentClass(String myString, @Nullable BigDecimal mySize) {
        this.setMyString(myString);
        this.setMySize((BigDecimal)Objects.requireNonNullElse(mySize, new BigDecimal((double)1.0F)));
    }

    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        } else if (obj != null && obj.getClass() == this.getClass()) {
            MyParentClass objMyParent = (MyParentClass)obj;
            return this.myString.equals(objMyParent.getMyString()) && this.mySize.equals(objMyParent.getMySize());
        } else {
            return false;
        }
    }

    public int hashCode() {
        return Objects.hash(new Object[]{this.myString, this.mySize});
    }
}

Child Class

package com.demo.app.example;
import com.fasterxml.jackson.annotation.*;
import com.fasterxml.jackson.annotation.JsonFormat.Shape;
import java.math.BigDecimal;
import java.util.Date;
import java.util.Objects;

import org.springframework.lang.Nullable;

public non-sealed class MyChildClass extends MyParentClass implements MySealedClass{
    public static String tag = "clearing::data::gen::product::AveragingFuture";
    @JsonProperty(required = true)
    @JsonFormat(
            shape = Shape.STRING,
            pattern = "yyyy-MM-dd"
    )
    private Date startDate;

    public Date getStartDate() {
        return this.startDate;
    }

    public void setStartDate(Date startDate) {
        this.startDate = startDate;
    }

    public MyChildClass() {
    }

    @JsonCreator
    public MyChildClass(@JsonProperty("myString")String myString,
                        @JsonProperty("mySize") @Nullable BigDecimal mySize,
                        @JsonProperty(value = "startDate", required = true) Date startDate) {
        super(myString, mySize);
        this.setStartDate((Date)Objects.requireNonNull(startDate));
    }

    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        } else if (obj != null && obj.getClass() == this.getClass()) {
            MyChildClass objMyChild = (MyChildClass)obj;
            return super.equals(obj) && this.startDate.equals(objMyChild.getStartDate());
        } else {
            return false;
        }
    }

    public int hashCode() {
        return Objects.hash(new Object[]{super.hashCode(), this.startDate});
    }
}

Expected behavior

I added a comment on the line of code that fails, i.e. the place where we are trying to deserialize a message for the parent type

Additional context

Thanks for your time in looking at this!

Metadata

Metadata

Assignees

No one assigned

    Labels

    2.19Issues planned at 2.19 or laterpolymorphic-deductionIssues related to "Id.DEDUCTION" mode of `@JsonTypeInfo`polymorphic-handlingProblem with polymorphic type handling (`@JsonTypeInfo`, Default Typing)

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions