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.
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
--- 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
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
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
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.