Polymorphic JSON with Jackson and Kotlin Sealed Types
Teach Jackson to round-trip your sealed-class ADTs with a single type discriminator field.
Explore the full explanation 8 ideas
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.
Let’s figure it out.
Ask me about this course or where to start.
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?