Repository navigation
Opt a hierarchy in with a Jackson mix-in - #3
Merged
Merged
Conversation
An alternative to #1 for the same problem: a sealed hierarchy whose source cannot be changed to extend SealedPolymorphismSupport. Instead of registering the type with the module, register a mix-in that extends the marker with the mapper: public interface AnimalMixIn extends SealedPolymorphismSupport {} JsonMapper.builder() .addModule(new SealedPolymorphismModule()) .addMixIn(Animal.class, AnimalMixIn.class) .build(); Following jackson-module-scala's ea02e0f, the mix-in carries the marker and nothing else, and lives in its own package as a third party's would. It does not extend the hierarchy it is mixed into and could not: a sealed type's permits clause names its subtypes, and someone who cannot change those classes cannot add themselves to it. Jackson never requires a mix-in to be a subtype of what it is mixed into. A mix-in does not change the type on the JVM, so unlike the marker it is not inherited by the implementations - only the root is opted in, and the module has to read that from the mapper's configuration. So the opt-in check and root resolution now take the MapperConfig, the walk up from a type can no longer stop at a supertype that has not opted in, and the deserializers take the resolved hierarchy rather than looking it up. Which root a type belongs to can now differ between mappers, so it is no longer cached by class. A root's name table is derived from the class files alone and is the same however that root opted in, so that half stays cached. A mix-in can also opt out: one carrying @JsonTypeInfo hands the hierarchy to Jackson exactly as the annotation on the class does, and one that does not extend the marker opts nothing in. 14 tests; 90 total. Everything a mixed-in hierarchy is held to is asserted against the same shapes as the marked ones: naming, concrete roots, mid-hierarchy roots, enums, not sealed, reopened below the root, and coexisting with a marked hierarchy in one mapper. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012hxYY9KPDAMXADWzjBHNV4
pjfanning
added a commit
that referenced
this pull request
Aug 23, 2026
The README opened by describing the module as polymorphic serialization
"without the annotations Jackson normally requires", which is not true:
@JsonTypeInfo on a sealed base is enough on its own, and Jackson reads
the permitted subclasses off the class file, so there is no @JsonSubTypes
to write either. A reader could have adopted this module believing it
filled a gap that is not there.
There is now a section up front showing the built-in form and saying to
use it if it suits, followed by an honest comparison. Verified against
3.1.6 rather than described from memory: @JsonTypeInfo alone round trips;
Id.SIMPLE_NAME yields the same shape this module writes; and a mix-in
carrying @JsonTypeInfo already covers a hierarchy whose source cannot be
changed.
One substantive difference survives that comparison. Two nested
implementations sharing a simple name - Boxed.Same and Nested.Same - both
write {"type":"Same"} under Id.SIMPLE_NAME, and both read back as
Nested.Same, so a Boxed.Same silently becomes something else. This module
derives Boxed$Same and Nested$Same and refuses at startup if two names
would still collide.
The Scala framing is softened throughout. It led with being a port of
jackson-module-scala#835 using the same property and naming rules, which
implied a cross-language compatibility that is neither claimed nor
tested - and that Java enums alone would break, since they are left to
Jackson as strings where the Scala module tags a case object. That is now
one "prior art" note at the end, saying so.
Also drops the paragraph claiming this does the job better than the Scala
module, and the table row saying mix-in opt-in was unsupported, which #3
has since merged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hxYY9KPDAMXADWzjBHNV4
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
An alternative to #1 for the same problem — a sealed hierarchy whose source cannot be changed to extend
SealedPolymorphismSupport. Instead of registering the type with the module, register a mix-in that extends the marker with the mapper. Branched offmain, so the two can be compared independently.Follows jackson-module-scala's ea02e0f.
The mix-in carries the marker and nothing else, and lives in its own package as a third party's would. It does not extend the hierarchy it is mixed into, and could not: a sealed type's
permitsclause names its subtypes, and someone who cannot change those classes cannot add themselves to it. Jackson never requires a mix-in to be a subtype of what it is mixed into.What this costs
A mix-in does not change the type on the JVM, so unlike the marker it is not inherited by the implementations — only the root is opted in, and the module has to read that from the mapper's configuration. Three consequences:
MapperConfig(as aMixInResolver).Which root a type belongs to can now differ between mappers, so that is no longer cached by class. A root's name table is derived from the class files alone and is identical however that root opted in, so that half stays cached.
There is no new public API at all —
addMixInis Jackson's, and the marker interface already exists.A mix-in can also opt out
SealedPolymorphismSupport@JsonTypeInfoas well as the markerHeld to the same rules
A mixed-in hierarchy must still be sealed, a
non-sealedmember is still reported, and enums are still left to Jackson. Mixing into a type part way down a hierarchy makes that type the root.Comparison with #1
registerSealedInterfaceOrClassTests
14 in
MixInTest, against a newMixInFixturesof ordinary sealed hierarchies that do not reference this module at all, plus mix-ins in a separatepoly.mixinpackage. Everything a mixed-in hierarchy is held to is asserted against the same shapes as the marked ones: naming, concrete roots, mid-hierarchy roots, enums, not sealed, reopened below the root, and coexisting with a marked hierarchy in one mapper.90 tests total.
🤖 Generated with Claude Code
https://claude.ai/code/session_012hxYY9KPDAMXADWzjBHNV4