Skip to content

Opt a hierarchy in with a Jackson mix-in - #3

Merged
pjfanning merged 1 commit into
mainfrom
mixin-opt-in
Aug 23, 2026
Merged

pjfanning merged 1 commit into
mainfrom
mixin-opt-in

Conversation

@pjfanning

Copy link
Copy Markdown
Owner

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 off main, so the two can be compared independently.

Follows jackson-module-scala's ea02e0f.

// yours, in your own package
public interface AnimalMixIn extends SealedPolymorphismSupport {}

ObjectMapper mapper = JsonMapper.builder()
        .addModule(new SealedPolymorphismModule())
        .addMixIn(Animal.class, AnimalMixIn.class)
        .build();

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.

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:

  • The opt-in check and root resolution take the MapperConfig (as a MixInResolver).
  • The walk up from a type can no longer stop at a supertype that has not opted in, since a mixed-in root may sit above several plain classes.
  • The deserializers take the resolved hierarchy rather than looking it up, since only their construction sites have the config.

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 — addMixIn is Jackson's, and the marker interface already exists.

A mix-in can also opt out

Mix-in Result
does not extend SealedPolymorphismSupport opts nothing in — the hierarchy stays untouched
carries @JsonTypeInfo as well as the marker the module stands down, as it does for the annotation on the class

Held to the same rules

A mixed-in hierarchy must still be sealed, a non-sealed member 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

#1 registerSealedInterfaceOrClass this PR, mix-in
Opt-in lives on the module the mapper
New public API one method none
Bad input reported at the register call when Jackson first meets the type
Per-mapper only if modules are not shared inherently
User writes nothing extra a one-line marker interface per hierarchy

Tests

14 in MixInTest, against a new MixInFixtures of ordinary sealed hierarchies that do not reference this module at all, plus mix-ins in a separate poly.mixin package. 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

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
pjfanning merged commit 6229a5d into main Aug 23, 2026
4 checks passed
@pjfanning
pjfanning deleted the mixin-opt-in branch August 23, 2026 19:33
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant