java 113 lines · 3 tabs

Deserialize Polymorphic JSON into a Sealed Interface Hierarchy with Jackson

Shared by codesnips Jul 2026
3 tabs
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonTypeInfo.As;
import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;

import java.math.BigDecimal;

@JsonTypeInfo(
    use = Id.NAME,
    include = As.EXISTING_PROPERTY,
    property = "type",
    visible = true
)
@JsonSubTypes({
    @JsonSubTypes.Type(value = PaymentMethod.Card.class, name = "card"),
    @JsonSubTypes.Type(value = PaymentMethod.BankTransfer.class, name = "bank_transfer"),
    @JsonSubTypes.Type(value = PaymentMethod.Wallet.class, name = "wallet")
})
public sealed interface PaymentMethod
    permits PaymentMethod.Card, PaymentMethod.BankTransfer, PaymentMethod.Wallet {

    String type();

    BigDecimal amount();

    record Card(BigDecimal amount, String panLast4, String network) implements PaymentMethod {
        @Override
        public String type() {
            return "card";
        }
    }

    record BankTransfer(BigDecimal amount, String iban, String bic) implements PaymentMethod {
        @Override
        public String type() {
            return "bank_transfer";
        }
    }

    record Wallet(BigDecimal amount, String provider, String accountRef) implements PaymentMethod {
        @Override
        public String type() {
            return "wallet";
        }
    }
}
3 files · java Explain with highlit

This snippet shows how a single JSON type discriminator can be mapped onto a Java sealed interface so that the compiler enforces exhaustiveness while Jackson handles the wire format. The domain in PaymentMethod sealed type models the mutually exclusive shapes a payment can take: a card, a bank transfer, or a wallet. Because the interface is sealed, the permitted set is fixed and known to the compiler, which is what makes the downstream switch in PaymentController safe without a default branch.

The key annotation is @JsonTypeInfo with use = Id.NAME and include = As.EXISTING_PROPERTY, which tells Jackson to read a discriminator from a real field named type rather than injecting a synthetic wrapper. The @JsonSubTypes block then binds each string value (card, bank_transfer, wallet) to a concrete record implementation. Using records keeps each variant immutable and gives Jackson a canonical constructor to bind against via @JsonCreator-style component matching, so no setters or no-arg constructors are needed. Each variant also re-declares the type() accessor returning a fixed value, satisfying the EXISTING_PROPERTY contract so the discriminator round-trips on serialization.

A subtle point is visible = true: without it Jackson would consume the type field for routing and never pass it to the record component, causing a binding failure. Marking it visible lets the same property serve both as the discriminator and as ordinary data.

In PaymentController, the @RequestBody PaymentMethod method parameter triggers this whole mechanism during request binding, and Spring's MappingJackson2HttpMessageConverter reuses the same configured ObjectMapper. The handler then pattern-matches with switch over the sealed type; because every permitted subtype is covered, the code is exhaustive and adding a new variant becomes a compile error until every branch is updated — turning a runtime ClassCastException risk into a build-time guarantee.

The main trade-off is coupling the JSON contract to the type hierarchy: renaming a discriminator value is a breaking API change, and unknown type values throw InvalidTypeIdException, which @ExceptionHandler maps to a 400. This pattern fits well when variants are genuinely closed and small; for open-ended plugin-style types a registry-based deserializer is a better fit.


Related snips

Share this code

Here's the card — post it anywhere.

Deserialize Polymorphic JSON into a Sealed Interface Hierarchy with Jackson — share card
Link copied