1
0
Files
jackson3-by-example/docs/part5-polymorphic.md
Ankur c438afc33b Jackson 3 series companion code
37 runnable examples covering the eight feature posts on ankurm.com, verified
against Jackson 3.2.1 on Temurin 21.0.5. Every output committed under docs/ was
produced by run-all.sh.

Also documents 11 places where the published snippets do not compile or do not
behave as printed against a real Jackson 3 build - most notably that
writeValueAsString(List<Base>) silently drops the polymorphic type discriminator,
so the post's serialised output cannot be read back.
2026-08-04 23:29:26 +05:30

4.5 KiB

Part 5 — Polymorphic deserialisation

Post: https://ankurm.com/jackson-polymorphic-deserialization/

This part contains the most consequential correction in the whole repo, so it gets the most space.

The defect

The post's "Serialising a Mixed List" section shows:

List<PaymentMethod> payments = List.of(card, bank);
String jsonOutput = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(payments);

and prints output containing "paymentType" : "credit_card".

It does not produce that. writeValueAsString(Object) sees only the runtime class of the argument — ImmutableCollections.ListN — which carries no element type. Without a declared element type Jackson never engages the polymorphic type serialiser, and the discriminator is silently omitted from every element. No exception, no warning.

The consequence is not cosmetic. That JSON cannot be read back: the deserialiser has no type id to dispatch on and throws InvalidTypeIdException. A service that writes with the post's code and reads with the post's code does not round-trip.

F01 — the defect and both fixes

F01SerialiseMixedList.java

--- 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}]
--- 3. FIX A: writerFor(TypeReference) ---
[ {
  "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
} ]
--- 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 1 shows a single element serialising correctly, which is why this is easy to miss in a unit test that only checks one object. Block 2 is the collection, discriminator absent. Blocks 3 and 4 are the two fixes:

  • mapper.writerFor(new TypeReference<List<PaymentMethod>>() {}) — declares the element type on the writer.
  • payments.toArray(new PaymentMethod[0]) — an array carries its component type at runtime, so no extra declaration is needed.

F02 — round-trip proof

F02DeserialiseMixedList.java

Card ending: 4242
Bank: National Bank
lossy JSON round-trip -> InvalidTypeIdException
correct JSON round-trip -> 2 payments, CreditCardPayment first

Deserialisation itself works exactly as the post describes; it is the serialisation side that is wrong. The middle two lines are the demonstration: lossy output fails, correctly written output survives.

F03 — the four include strategies

F03IncludeStrategies.java

The post gives these as a table. Here they are executed, with the read-back to confirm each wire format is symmetric.

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]

EXISTING_PROPERTY needs visible = true and a real field of that name on the type; without it the discriminator is written but not populated back onto the object.

F04 — bad discriminators

F04UnknownTypeId.java

unknown logical name           -> InvalidTypeIdException
attacker-supplied class name   -> InvalidTypeIdException
missing discriminator          -> InvalidTypeIdException

All three fail the same way, which is the reassuring answer: an unregistered logical name, an attacker-supplied fully qualified class name, and a missing discriminator are indistinguishable to the type resolver, and none of them instantiate anything.