Skip to main content

Jackson Polymorphic Deserialisation with @JsonTypeInfo (Jackson 3): Labels, Sealed Types and Safe Defaults

How to deserialise one JSON shape into several Java subtypes with Jackson 3: @JsonTypeInfo and @JsonSubTypes, the list that silently loses its type label, sealed-class subtypes, unknown labels, and what changed in default typing and type validation. Every output is from a run.

A JSON document does not say what kind of thing it is. {"amountDue":99.99,"cardNetwork":"VISA"} is obviously a card payment to you, but to a program that was asked for “a payment method” it is just keys and values, and a payment method is an abstract idea: there is no object to create until someone picks a concrete class. Polymorphic deserialisation is how you tell Jackson which class to pick, by putting a short label in the JSON itself and giving Jackson a list of the labels it may believe. This article starts with that idea and builds up to the parts that bite: a way of writing a list that silently throws the label away, what changed in Jackson 3 around default typing, and when a label in the JSON must not be allowed to choose a class. The detail is in expandable boxes, so you can read straight through or open only what you need.
Versions, and what this was run on. Jackson 3.2.3 (tools.jackson.core:jackson-databind), with the annotations still in com.fasterxml.jackson.annotation (annotations 2.22, which Jackson 3 deliberately shares with Jackson 2), Java 25 (the project compiles for release 21). Every example is a plain main() in asmhatre/jackson3-by-example and every console block below is quoted from a file in its docs/output/. Nothing here ran inside Spring or against a network service; this is Jackson on its own. An earlier version of this article said Jackson 3.2.1 on JDK 21.

The reader needs a label, and the label needs a list of allowed names

Suppose an API returns a list of payment methods. Some are credit cards, some are bank transfers, and they have different fields. Without help, Jackson meets the declared type PaymentMethod, which is abstract, and cannot choose between CreditCardPayment and BankTransferPayment. The fix has two halves. The writer adds a label to each object (here "paymentType":"credit_card"). The reader looks at the label, finds it in a registry of allowed names, and creates the class registered under that name.
JSON objectpaymentType: credit_cardType resolverlooks the label upregistry (the allow-list):CreditCardPaymentcredit_cardBankTransferPaymentbank_transferany other label: InvalidTypeIdException
The picture is the whole mechanism. The registry is the important part: it is a closed list, written in your code, and a label that is not on it never turns into a class. Everything later in this article, including the security warnings, is about keeping that list closed.
Going deeper: what “abstract” means for Jackson
Jackson builds objects by finding a constructor or factory for a concrete class. An interface or abstract class has none, so without a label it cannot proceed. A sealed interface (a Java 17 feature that lists its permitted implementations) is still abstract as far as Jackson is concerned, and needs the same label; a later section shows how sealing can replace the registry.

Two annotations: where the label lives and which names exist

@JsonTypeInfo says how types are identified. @JsonSubTypes is the registry. Both go on the base class, in PaymentMethod.java:
@JsonTypeInfo(
        use      = JsonTypeInfo.Id.NAME,      // use a logical name as the discriminator
        include  = JsonTypeInfo.As.PROPERTY,  // embed it as a field in the JSON object
        property = "paymentType"              // the JSON key that carries the type name
)
@JsonSubTypes({
        @JsonSubTypes.Type(value = CreditCardPayment.class,   name = "credit_card"),
        @JsonSubTypes.Type(value = BankTransferPayment.class, name = "bank_transfer")
})
The three settings of @JsonTypeInfo are three separate decisions. use = Id.NAME means “identify a type by a logical name that I choose”, as opposed to its Java class name. include = As.PROPERTY means “write the name as a normal field inside the object”. property = "paymentType" is the field’s name. The concrete classes are ordinary beans with no annotations at all, for example CreditCardPayment.java and BankTransferPayment.java.
Going deeper: why logical names beat class names
A logical name decouples the wire format from your code. Rename or move CreditCardPayment and nothing the clients see changes, because the registry still maps credit_card to it. The alternative, Id.CLASS, writes the fully qualified class name into the JSON, which breaks on a rename and, worse, makes the JSON an instruction to load a class by name. A later section runs that and shows what Jackson 3 does about it.

Reading a mixed list works the way you expect

Reading is the half that behaves. Tell Jackson the target is a list of the base type, and each element is dispatched on its own label. The example is F02DeserialiseMixedList.java; the part that matters is one call:
        List<PaymentMethod> payments =
                mapper.readValue(GOOD_JSON, new TypeReference<List<PaymentMethod>>() { });
The TypeReference is needed because of generic erasure: at run time a List<PaymentMethod> is just a List, so you have to hand Jackson the element type yourself. The consumer then uses pattern matching (instanceof CreditCardPayment cc) to reach each subtype’s own fields. Output, from F02DeserialiseMixedList.txt:
Card ending: 4242
Bank: National Bank

Writing a list is where the label silently disappears

Now the same list in the other direction. This looks right and is wrong: mapper.writeValueAsString(payments). The earlier version of this article showed it producing JSON with the paymentType label. It does not. The cause is in the diagram below: when you pass a List to writeValueAsString, Jackson sees only the object’s run-time class, which knows nothing about its elements’ declared type, so it never asks for a type label and writes none. No exception, no warning.
writeValueAsString(list)runtime class: ImmutableCollections.ListNcarries no element typeno type serialiser engagedelements written as plain objectsJSON has no paymentTypeand cannot be read backwriterFor(TypeReference) or a typed arraydeclared type: List<PaymentMethod>the element type is statedtype serialiser engagedeach element gets its labelJSON round-trips
The left side is what the old code did; the right side is the fix. F01SerialiseMixedList.java runs all four variants, and the output (F01SerialiseMixedList.txt) shows the difference. Block 1 is a single payment, which is correct and is why a one-element unit test will not catch the problem. Block 2 is the list with the label missing. Block 4 is the typed-array fix:
--- 1. single element: discriminator present ---
{"paymentType":"credit_card","amountDue":99.99,"cardNetwork":"VISA","cardNumberLastFour":"4242","paymentId":1}
--- 2. BROKEN: writeValueAsString(List) drops paymentType ---
[{"amountDue":99.99,"cardNetwork":"VISA","cardNumberLastFour":"4242","paymentId":1},{"amountDue":250.0,"bankAccountIban":"GB29NWBK60161331926819","bankName":"National Bank","paymentId":2}]
--- 4. FIX B: a typed array carries its component type ---
[{"paymentType":"credit_card","amountDue":99.99,"cardNetwork":"VISA","cardNumberLastFour":"4242","paymentId":1},{"paymentType":"bank_transfer","amountDue":250.0,"bankAccountIban":"GB29NWBK60161331926819","bankName":"National Bank","paymentId":2}]
Block 3, not repeated here, is the pretty-printed fix with writerFor(new TypeReference<List<PaymentMethod>>() { }). And the consequence is not cosmetic: the label-less JSON cannot be read back. The round-trip test in F02DeserialiseMixedList.txt shows both halves:
lossy JSON round-trip -> InvalidTypeIdException
correct JSON round-trip -> 2 payments, CreditCardPayment first
Test the collection, not the element. A test that serialises one PaymentMethod passes while a service that writes a List<PaymentMethod> produces unreadable JSON. If the same code writes and reads, the failure appears only when the reader meets the output. Round-trip a list in a test.
Going deeper: declared type, not run-time type
The rule behind this: Jackson finds the type serialiser from the declared type, and a single object passed to writeValueAsString carries it because the root type is that object’s own annotated class. A list’s elements have no declared type at run time because of generic erasure, which is the same reason readValue needs a TypeReference on the way in. Three fixes work: writerFor(TypeReference), a typed array (payments.toArray(new PaymentMethod[0]), because an array carries its component type), or a wrapper record with a typed List<PaymentMethod> field. I tested the first two.

Four places to put the label

include controls where the label goes. The choice matters when an outside system dictates the shape of the JSON. The four options and their JSON:
Include strategyDescriptionExample JSON
As.PROPERTYLabel is a regular field inside the object{"paymentType":"credit_card","amountDue":99}
As.WRAPPER_OBJECTObject wrapped in a single-key envelope{"credit_card":{"amountDue":99}}
As.WRAPPER_ARRAYLabel and object in a two-element array["credit_card",{"amountDue":99}]
As.EXISTING_PROPERTYUses a field the class already has; adds none{"paymentType":"credit_card","amountDue":99}
The table is the idea; F03IncludeStrategies.java executes it so you can see the real wire format (F03IncludeStrategies.txt):
PROPERTY          : {"kind":"card","amountDue":99.0}
WRAPPER_OBJECT    : {"card":{"amountDue":99.0}}
WRAPPER_ARRAY     : ["card",{"amountDue":99.0}]
EXISTING_PROPERTY : {"kind":"card","amountDue":99.0}
read PROPERTY       -> PropCard[amountDue=99.0]
read WRAPPER_OBJECT -> WrapObjCard[amountDue=99.0]
read WRAPPER_ARRAY  -> WrapArrCard[amountDue=99.0]
As.PROPERTY is the common and most readable choice. The wrappers are mainly for matching a format someone else defined. EXISTING_PROPERTY is the odd one: it adds nothing when writing, so the class must already carry the field, and it needs visible = true if you want the label copied onto that field when reading.
Going deeper: the EXISTING_PROPERTY condition
In the example the record ExistingCard(String kind, double amountDue) has its own kind component, and the annotation sets visible = true. The repository notes say that without visible the label is consumed by the type resolver and the field is not populated; I did not run that failing variant, the example shows only the working form.

Sealed types: let permits be the registry

A hand-written @JsonSubTypes list has one weakness: it can drift. Add a subtype to the hierarchy, forget the registry, and the new type fails only when its first JSON arrives. A Java sealed interface already lists its permitted implementations in a permits clause, and Jackson 3 reads that list. Put @JsonTypeName on each record and drop @JsonSubTypes entirely. F07SealedHierarchies.java builds a hierarchy two levels deep:
    @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind")
    public sealed interface Event permits Payment, Refund, Audit { }

    @JsonTypeName("payment") public record Payment(String id, double amount) implements Event { }
    @JsonTypeName("refund")  public record Refund(String id, double amount) implements Event { }

    /** A sealed sub-hierarchy: its permitted records are discovered through Event too. */
    public sealed interface Audit extends Event permits Login, Logout { }
    @JsonTypeName("login")  public record Login(String user) implements Audit { }
    /** Deliberately NO @JsonTypeName: the id falls back to the simple class name. */
    public record Logout(String user) implements Audit { }
It is also a switch you can trust: because Event is sealed, a switch over it needs no default, and a new permitted type is a compile error until every switch handles it. Output (F07SealedHierarchies.txt):
each as Event.class:
  {"kind":"payment","id":"p1","amount":10.0}
  {"kind":"refund","id":"r1","amount":4.0}
  {"kind":"login","user":"ann"}
  {"kind":"F07SealedHierarchies$Logout","user":"ann"}
round trip equal: true
  payment 10.0
  refund 4.0
  login ann
  logout ann
nested sealed type read as Audit.class: Login[user=bo]
Everything round-trips, and the nested sealed interface Audit can also be used as a target on its own (last line). Look at the fourth JSON line, though: Logout has no @JsonTypeName, and Jackson used the name F07SealedHierarchies$Logout. That is the class name without its package, and for a nested type it includes the enclosing class.
Give every permitted type an explicit name. A name derived from a class name changes the day someone moves or renames the class, and the wire format changes with it. The example leaves one name out on purpose so that you can see what that looks like.
Going deeper: what I checked and what I did not
The auto-discovery behaviour is demonstrated in this example and in C04SealedAutoDiscovery.java (a top-level sealed interface where a third type is added with a single line). The explicit-registry form is C03SealedWithSubTypes.java. I did not compare the behaviour with Jackson 2, so the claim that Jackson 2 needed the registry rests on the repository’s earlier comments, not on a run here.

When the label is wrong, or from the future

By default, a label that is not in the registry is an error. F04UnknownTypeId.java tries three bad inputs: an unregistered name, an attacker-style fully qualified class name, and no label at all (F04UnknownTypeId.txt):
unknown logical name           -> InvalidTypeIdException
attacker-supplied class name   -> InvalidTypeIdException
missing discriminator          -> InvalidTypeIdException
All three fail the same way and none instantiates anything, which is the safe answer. The cost appears when a newer producer starts sending a type your reader has not been updated for: the whole read fails. F08UnknownSubtypes.java shows the two ways to be tolerant and what each does (F08UnknownSubtypes.txt):
defaultImpl, known name                              -> Click[x=3]
defaultImpl, unknown name 'swipe'                    -> Unknown[]
defaultImpl, class-name attack                       -> Unknown[]
defaultImpl, discriminator missing                   -> Unknown[]
FAIL_ON_INVALID_SUBTYPE off, unknown name            -> null
FAIL_ON_INVALID_SUBTYPE off, missing discriminator   -> null
default, unknown name                                -> InvalidTypeIdException
The first group uses defaultImpl: a catch-all type that receives anything unrecognised. The second turns off FAIL_ON_INVALID_SUBTYPE, after which an unknown name gives a null result, which is easy to mistake for “no value” downstream.
A tolerant reader hides the problem as well as the crash. With defaultImpl, the attacker-style class name and the missing label both came back as the catch-all, with no exception. That is safe here because the catch-all carries no data, but it also means a typo in a producer’s label is silent. If you use it, count or log every catch-all result.
Going deeper: choosing between the two
defaultImpl keeps the registry closed and gives you a real object to log and route; it is the better default. Turning off FAIL_ON_INVALID_SUBTYPE is a mapper-wide switch that affects every polymorphic type the mapper reads, including ones you did not mean to loosen. Note also that in the run above, with the switch off, a payload with no label at all gave null too, not an error.

When the JSON chooses the class

Everything so far used logical names, so the JSON could only pick from your registry. Id.CLASS is different: the label is a Java class name, and Jackson loads whatever class the JSON names, as long as it is a subtype of the declared type. That is convenient and dangerous, because it turns data into a decision about which code runs. In F06TypeValidation.java, the base class Plugin uses Id.CLASS and lists only Good in @JsonSubTypes. Another subtype, Rogue, exists but was never meant to come from the wire. Six reads, three mapper configurations (F06TypeValidation.txt):
default mapper, Good (listed in @JsonSubTypes)             -> accepted Good
default mapper, Rogue (not listed)                         -> accepted Rogue
FAIL_ON_SUBTYPE_CLASS_NOT_REGISTERED, Good                 -> accepted Good
FAIL_ON_SUBTYPE_CLASS_NOT_REGISTERED, Rogue                -> InvalidTypeIdException
PolymorphicTypeValidator allowing only Good, Good          -> accepted Good
PolymorphicTypeValidator allowing only Good, Rogue         -> InvalidTypeIdException
The first two lines are the point. A default mapper accepted Rogue, which was not in @JsonSubTypes, simply because the JSON named it. Two Jackson 3 controls close that. FAIL_ON_SUBTYPE_CLASS_NOT_REGISTERED is a deserialisation feature that requires the named class to be registered. A PolymorphicTypeValidator passed to the mapper builder is an allow-list of types checked before anything is created. Either one rejects Rogue and still accepts Good.
Prefer logical names; if you cannot, add a validator. Rogue here is harmless, since it only has a field, and that is deliberate: the check is on the name, so it protects you even when the class you forgot about is not harmless. The security article covers the attacks this mechanism enables. I did not run any gadget class; the safety claim rests on the allow-list rejecting the name.
Going deeper: the validator builder
BasicPolymorphicTypeValidator.builder() offers allowIfSubType and allowIfBaseType, each taking a class, a package-prefix string or a regular expression, plus allowIfSubTypeIsArray(). Prefer exact classes or your own package prefix over broad prefixes such as java.. In the run above only Good is allowed, so nothing else passes.

Default typing, and what Jackson 3 changed

Default typing is the second way types get into JSON: no annotation on any class, and instead a rule on the mapper such as “write a type label for every value whose declared type is not final”. It exists for graphs where you cannot annotate the classes. Jackson 3 changed its shape in three ways that you can check on the classpath. First, the setting lives on the builder: a built mapper has no activateDefaultTyping (and no setters at all), proven by reflection in H02DefaultTypingRemoved.java. Second, the rule enum is now a top-level type, tools.jackson.databind.DefaultTyping, with six constants. Third, the validator is a required argument of activateDefaultTyping, so you cannot switch it on without deciding what is allowed. What each constant does is easier to see than to describe. F05DefaultTypingModes.java writes the same object (an Object field, an interface field, a record, a final class, an enum, an array, a list) under each rule (F05DefaultTypingModes.txt). The first lines of the output, trimmed to three of the constants:
JAVA_LANG_OBJECT:
  {"any":["~Tag",{"label":"t"}],"colour":"RED","dot":{"x":2},"items":["a"],"numbers":[1,2],"shape":{"x":1},"tag":{"label":"t"},"text":"s"}
OBJECT_AND_NON_CONCRETE:
  {"any":["~Tag",{"label":"t"}],"colour":"RED","dot":{"x":2},"items":["java.util.ImmutableCollections$List12",["a"]],"numbers":[1,2],"shape":["~Dot",{"x":1}],"tag":{"label":"t"},"text":"s"}
NON_FINAL_AND_RECORDS:
  ["~Holder",{"any":["~Tag",{"label":"t"}],"colour":"RED","dot":["~Dot",{"x":2}],"items":["java.util.ImmutableCollections$List12",["a"]],"numbers":[1,2],"shape":["~Dot",{"x":1}],"tag":{"label":"t"},"text":"s"}]
Reading them: with JAVA_LANG_OBJECT only the field declared as Object gets a label ("any":["~Tag",...], where ~ stands for the example’s package and class prefix, shortened in the run). OBJECT_AND_NON_CONCRETE adds the interface-typed field and the list. NON_FINAL_AND_RECORDS labels almost everything, including the root object and the final record, which plain NON_FINAL leaves alone.
The root object is labelled too. Under NON_FINAL and its variants the whole document becomes ["~Holder",{...}], so a validator that allows only the payload types rejects even the happy path. That is the first gotcha people hit, and the reason the repository’s allow-list example allows the envelope type as well.
Going deeper: the other constants, and the older advice
The full output also shows NON_CONCRETE_AND_ARRAYS and NON_FINAL_AND_ENUMS; the latter additionally labels enum values ("colour":["~Colour","RED"]). Advice from Jackson 2 articles, including the Jackson 2 form mapper.activateDefaultTyping(validator, ...) on a built mapper, does not compile against Jackson 3. If your design needs default typing at all, the safest form is a validator that allows only your own packages and a rule narrower than NON_FINAL.

Should you even do this?

Often a plain field is simpler. If a value has two or three shapes and you control both ends, an enum plus nullable fields, or two separate endpoints, avoids polymorphic JSON altogether and cannot be tricked by a label. Reach for @JsonTypeInfo when the hierarchy is real, the JSON crosses a boundary (a queue, an API), and the set of types is closed. Use logical names, round-trip a list in a test, name every permitted type, and decide on purpose what an unknown label does. What I did not test: Jackson 2 for comparison, any real attack payload, performance, the Spring integration, and the failing form of EXISTING_PROPERTY without visible.
Frequently asked questions

Why does my list serialise without the type label?

Because the list’s declared element type is lost at run time. Use writerFor(new TypeReference<List<YourBase>>() { }) or write a typed array. Block 2 of F01SerialiseMixedList.txt is the broken output.

Do I still need @JsonSubTypes with sealed interfaces?

Not in the run here: with a sealed interface and @JsonTypeName on each permitted record, Jackson 3.2.3 round-tripped the hierarchy with no registry (F07SealedHierarchies.txt).

Is Id.CLASS safe?

Not by itself. In F06TypeValidation.txt a default mapper accepted a class that was not in @JsonSubTypes. Add FAIL_ON_SUBTYPE_CLASS_NOT_REGISTERED or a PolymorphicTypeValidator, or use Id.NAME.

Where did activateDefaultTyping go?

It moved to JsonMapper.builder(). See H02DefaultTypingRemoved.java for the reflective check.
AI prompts for polymorphic deserialisation

Add Type Discriminator to Class Hierarchy

I have this abstract class and its concrete subclasses: [paste hierarchy here]. Add @JsonTypeInfo and @JsonSubTypes to make the hierarchy fully serialisable and deserialisable with Jackson 3. Use a “type” property as the discriminator. Show the expected JSON for each subclass, demonstrate deserialising a mixed-type list, and include a round-trip JUnit 5 test using JsonMapper that verifies each subclass round-trips correctly. What it does: Applies @JsonTypeInfo(use=Id.NAME, include=As.PROPERTY, property="type") to the base class and a complete @JsonSubTypes registry to every concrete subclass, then generates sample JSON for each variant and a parameterised JUnit 5 test that verifies every subtype survives a full serialise-then-deserialise round-trip. When to use it: When designing an event-driven, command-pattern, or notification system where a base type must be dispatched to multiple concrete handlers and the exact type must survive JSON serialisation across a message bus or REST API boundary.

Compare All Four @JsonTypeInfo Strategies

Using this class hierarchy: [paste hierarchy here], show all four @JsonTypeInfo placement strategies: PROPERTY (type field inline), WRAPPER_OBJECT (type as outer key), WRAPPER_ARRAY (type as first array element), and EXISTING_PROPERTY (use a field already present in the class). For each strategy, show the annotated class, a sample serialised JSON string, and one sentence describing the trade-off — readability vs compatibility with external schemas. What it does: Generates four annotated variants of the same hierarchy, each with its corresponding sample JSON and a trade-off summary. The side-by-side format makes it straightforward to evaluate which discriminator strategy fits your API contract without having to experiment manually. When to use it: When choosing a type discriminator strategy for a new API and needing to see the exact wire-format impact of each option before committing. Especially useful when integrating with an existing external system that already has a fixed discriminator format you must match.

Refactor to Sealed Interface + Records

I have this @JsonTypeInfo abstract class hierarchy: [paste here]. Refactor it to use a Java sealed interface with record implementations in Java 21, compatible with Jackson 3. Replace the abstract class with a sealed interface, convert each concrete subclass to a record that implements the interface, update the @JsonTypeInfo and @JsonTypeName annotations, and confirm whether @JsonSubTypes can be removed. Show a before-and-after comparison and a working deserialisation test. What it does: Refactors the abstract class + concrete subclass pattern to a sealed interface + permitted records pattern, updates all Jackson annotations to the Jackson 3 style, and shows whether the @JsonSubTypes registry can be dropped in favour of @JsonTypeName on each record — producing a leaner, more type-safe hierarchy with exhaustive pattern matching support. When to use it: When modernising a legacy polymorphic Jackson model to take advantage of Java 21 sealed types and pattern matching, or when a code review flags that @JsonSubTypes is out of sync with the actual subclass set and you want the compiler rather than runtime to enforce completeness.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.