Polymorphic JSON with Jackson and Kotlin Sealed Types
Teach Jackson to round-trip your sealed-class ADTs with a single type discriminator field.
Polymorphism is the one place where JSON and Kotlin's type system disagree the most. A `sealed interface` or `sealed class` models a closed set of variants beautifully in code, but on the wire JSON is just untyped objects. When you serialize a `PaymentMethod` that is really a `CreditCard`, the resulting JSON has no idea which subtype it came from. On the way back in, Jackson sees an abstract type and has no way to pick a concrete class. The fix is to embed a small type discriminator in the JSON and tell Jackson how to read it. That is exactly what `@JsonTypeInfo` and `@JsonSubTypes` do.
`@JsonTypeInfo` is placed on the base type (the sealed interface or class) and answers two questions: how is the type encoded, and where does it live? The most common and most readable choice is `use = JsonTypeInfo.Id.NAME` together with `include = JsonTypeInfo.As.PROPERTY` and a `property = "type"`. This produces a logical name (like `"credit_card"`) stored in a sibling field called `type`, rather than a fully-qualified Java class name. Using logical names instead of `Id.CLASS` is important: class names leak your package structure, break the moment you rename or move a class, and are a security risk because they let a payload pick which class gets instantiated.
`@JsonSubTypes` is the registry that maps each logical name to a concrete subclass. You annotate the base type with a list of `@JsonSubTypes.Type` entries, each pairing a `value` (the Kotlin class) with a `name` (the discriminator string). Because the names are explicit, the JSON contract is now decoupled from your code: you can rename `CreditCard` to `CardPayment` in Kotlin without changing a single byte of the public API, as long as the `name = "credit_card"` stays put. This is the whole reason to prefer the annotation approach over reflection-based defaults.
Kotlin sealed types are the ideal carrier for this pattern because exhaustiveness is enforced by the compiler, not by hope. Once Jackson hands you back a `PaymentMethod`, a `when` expression over its subtypes needs no `else` branch, and adding a new variant turns every consumer into a compile error until it is handled. Pair that with `data class` variants so you get structural `equals`, `copy`, and a sane `toString` for free. Annotate the variants' constructor parameters, and remember that Jackson on Kotlin needs the `jackson-module-kotlin` module registered so it can see your primary constructors and `val` properties.
Round-tripping an ADT means `deserialize(serialize(x)) == x` for every value in the type. With `data class` variants this equality check is real and testable, which makes it a great invariant to assert in a unit test. The discriminator field is symmetric: Jackson writes it on the way out and consumes it on the way in, so as long as both sides share the same `ObjectMapper` configuration, values survive the trip intact. Watch out for two gotchas: a missing discriminator in incoming JSON throws an `InvalidTypeIdException`, and an unknown discriminator value does too unless you configure a `defaultImpl` fallback.
Configuration matters as much as the annotations. Register the Kotlin module (`jacksonObjectMapper()` from `jackson-module-kotlin` does this for you), and for Kotlin's nullability to be respected, that module is what teaches Jackson that a non-null `val` is actually required. In a Spring Boot 3 application you usually do not build the mapper by hand at all; Boot autoconfigures one and, if `jackson-module-kotlin` is on the classpath, registers it automatically. You can still customize it with a `Jackson2ObjectMapperBuilderCustomizer` bean to add modules like `JavaTimeModule` or to set `FAIL_ON_UNKNOWN_PROPERTIES` to false for forward compatibility.
There is also a placement subtlety worth internalizing. `include = As.PROPERTY` puts the discriminator alongside the data fields, which is the friendliest format for most REST clients. `As.EXISTING_PROPERTY` is the right choice when the subtype itself already declares a `val type` field that you want serialized as both real data and the discriminator, avoiding a duplicate or phantom field. `As.WRAPPER_OBJECT` nests the payload under a key named by the type, which some event schemas prefer. Pick one deliberately and document it, because changing it later is a breaking change to every stored or in-flight message.
Finally, this pattern scales naturally to event sourcing and message queues, where you persist a stream of heterogeneous events as JSON and must reconstruct their exact Kotlin types later. A sealed `DomainEvent` hierarchy with stable discriminator names gives you a durable, versionable contract: old events keep deserializing because their `name` never changes, and new event types are added to `@JsonSubTypes` without disturbing the rest. Keep the discriminator names in a single place, treat them as part of your public schema, and never derive them from class names you might one day refactor.
import com.fasterxml.jackson.annotation.JsonSubTypesimport com.fasterxml.jackson.annotation.JsonTypeInfo@JsonTypeInfo(use = JsonTypeInfo.Id.NAME,include = JsonTypeInfo.As.PROPERTY,property = "type")@JsonSubTypes(JsonSubTypes.Type(value = PaymentMethod.CreditCard::class, name = "credit_card"),JsonSubTypes.Type(value = PaymentMethod.BankTransfer::class, name = "bank_transfer"),JsonSubTypes.Type(value = PaymentMethod.Cash::class, name = "cash"))sealed interface PaymentMethod {data class CreditCard(val last4: String, val brand: String) : PaymentMethoddata class BankTransfer(val iban: String) : PaymentMethoddata object Cash : PaymentMethod}
The core pattern: a sealed interface annotated with @JsonTypeInfo + @JsonSubTypes. The 'type' field is the discriminator; logical names decouple the JSON contract from class names.
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapperimport com.fasterxml.jackson.module.kotlin.readValueval mapper = jacksonObjectMapper()val original: PaymentMethod = PaymentMethod.CreditCard(last4 = "4242", brand = "visa")val json = mapper.writeValueAsString(original)// {"type":"credit_card","last4":"4242","brand":"visa"}val restored: PaymentMethod = mapper.readValue(json)check(restored == original) // structural equality from data classwhen (restored) { // exhaustive, no else neededis PaymentMethod.CreditCard -> println("card ****" + restored.last4)is PaymentMethod.BankTransfer -> println("iban " + restored.iban)PaymentMethod.Cash -> println("cash")}
Round-tripping with the Kotlin module. jacksonObjectMapper() pre-registers jackson-module-kotlin so primary-constructor vals and nullability are honored. Note the emitted 'type' field.
import com.fasterxml.jackson.databind.DeserializationFeatureimport org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizerimport org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configuration@Configurationclass JacksonConfig {@Beanfun jacksonCustomizer() = Jackson2ObjectMapperBuilderCustomizer { builder ->builder.featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)}}// Optional fallback for unrecognized discriminator values:@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", defaultImpl = DomainEvent.Unknown::class)sealed interface DomainEvent {data class Unknown(val raw: String? = null) : DomainEvent}
Spring Boot 3: customize the autoconfigured ObjectMapper instead of building your own. defaultImpl gives a safe fallback when an unknown discriminator arrives, aiding forward compatibility.
🧠Check your understanding
0/1 · 0/1 answered1. Why is `@JsonTypeInfo(use = JsonTypeInfo.Id.NAME)` with `@JsonSubTypes` preferred over `use = JsonTypeInfo.Id.CLASS` for a sealed hierarchy?