{"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 incom.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 plainmain()in asmhatre/jackson3-by-example and every console block below is quoted from a file in itsdocs/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 typePaymentMethod, 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.
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.
PaymentMethod.java— the abstract base used throughout.- Jackson annotations cheat sheet — the other annotations used here.
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.
CreditCardPayment.java— a plain subtype.- JsonTypeInfo Javadoc
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 isF02DeserialiseMixedList.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.
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 onePaymentMethodpasses while a service that writes aList<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.
F01SerialiseMixedList.java— the four variants.- docs/part5-polymorphic.md — the original write-up of the defect.
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 strategy | Description | Example JSON |
|---|---|---|
As.PROPERTY | Label is a regular field inside the object | {"paymentType":"credit_card","amountDue":99} |
As.WRAPPER_OBJECT | Object wrapped in a single-key envelope | {"credit_card":{"amountDue":99}} |
As.WRAPPER_ARRAY | Label and object in a two-element array | ["credit_card",{"amountDue":99}] |
As.EXISTING_PROPERTY | Uses a field the class already has; adds none | {"paymentType":"credit_card","amountDue":99} |
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.
F03IncludeStrategies.java— all four strategies as small hierarchies.
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.
- Records, Optionals and sealed classes with Jackson — the broader treatment.
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.
F08UnknownSubtypes.java— both approaches.
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.
F06TypeValidation.java— the six reads.H03PolymorphicTypeValidatorAllowlist.java— an allow-list used with default typing.- Jackson security best practices — the full security treatment.
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 noactivateDefaultTyping (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. UnderNON_FINALand 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.
F05DefaultTypingModes.txt— all six constants.H02DefaultTypingRemoved.java— what the mapper no longer has.H01SafePolymorphismByAnnotation.java— the annotation form this article recommends.
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@JsonTypeInfowhen 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 ofEXISTING_PROPERTYwithoutvisible.
Frequently asked questions
Why does my list serialise without the type label?
Because the list’s declared element type is lost at run time. UsewriterFor(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. InF06TypeValidation.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 toJsonMapper.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
- The code: asmhatre/jackson3-by-example, package
part5polymorphic, with outputs indocs/output/. - Jackson Security Best Practices — default typing, allow-lists and resource limits.
- Jackson Annotations Cheat Sheet — the annotations used here.
- Custom Serialisers and Mix-ins — for types you cannot annotate.
- Streaming API and JsonNode — reading the label by hand.
- Records, Optionals and sealed classes
- JsonTypeInfo Javadoc
No Comments yet!