Leave Java enums entirely to Jackson - #2
Merged
Merged
Conversation
An enum permitted by a handled root was being written as a tagged object
with a name per constant - {"@type":"Status$IDLE"} - which took over a
representation Jackson already has. Enums are now skipped everywhere:
Jackson writes them as strings and this module does not interfere.
That removes the only thing a resolved name could be other than a class,
so Subtype and the singleton it carried are gone, along with
TypeTaggedEnumSerializer, TaggedEnumDeserializer and the enum modifier
hooks. A name now resolves to a Class and nothing else.
Putting the marker on an enum is no longer an error either - it simply
has no effect, since an enum is written as a string however it is
declared.
The consequence is that an enum member has no @type name, so a value of
one cannot be read back through the hierarchy's base type: a string is
not something the base can dispatch on. Writing works, and reading works
wherever the property is declared as the enum type itself. The failure
explains this rather than just reporting a token mismatch, naming the
enums the hierarchy permits and pointing at the alternatives.
The enum tests are kept with corrected assertions, and EnumsUntouched
proves non-interference by writing each value through a mapper with the
module and a plain one and asserting the two agree - which catches
differences that were not predicted. It covers enums declared at the
base and at their own type, in collections, as map keys, with a custom
@jsonvalue, and with constant bodies, that last being implicitly sealed
and abstract with each constant an anonymous subclass, so it reaches
every sealed check the module makes.
76 tests.
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
Four things no longer matched the code after #2: The port is described as name-for-name compatible with the Scala module. That still holds for classes, but enums are now the one place the two diverge - Scala tags a case object where this module leaves a Java enum to Jackson as a string. The closed-hierarchy section required every handled type to be sealed or final, and listed a marked enum in the same table as the errors. Enums sit outside that rule entirely now: one permitted by the root is skipped rather than checked, whether it is final or implicitly sealed through constant bodies, and the marker on an enum is ignored rather than rejected. InvalidHierarchyTest is described as covering "the four ways a hierarchy can fail to be closed". There are three - not sealed, reopened by a non-sealed member, clashing derived names - and the fourth case now asserts that a marked enum is ignored. Also: the status line still said the module was not published anywhere, which the Installing section two paragraphs later contradicted. 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
Opting in meant extending SealedPolymorphismSupport, which requires
editing the base type. That is not possible for a hierarchy from a
library or from generated code, so a root can be registered instead:
new SealedPolymorphismModule()
.registerSealedInterfaceOrClass(Animal.class)
.registerSealedInterfaceOrClass(Shape.class)
registerSealedInterfaceOrClass takes one type and is callable as often
as needed. Registering adds to what the module already handles through
the marker rather than replacing it, so marked and registered
hierarchies work through the same mapper.
A registered hierarchy is handled identically to a marked one - same
names, same reading, same conflict rules - and is held to the same
requirement that it be sealed. Registering null, a type that is not
sealed, an enum, or one carrying @JsonTypeInfo is refused by the
register call rather than later when Jackson first meets the type.
@JsonTypeInfo is refused because it already tells Jackson how to write
and read the hierarchy, so registering as well would ask for two type
properties at once. Nothing is relaxed below the root either: a
non-sealed member is still reported.
Register the root; implementations follow from its permits clause.
Registering part way down a hierarchy is allowed and makes that type the
root, so names are derived relative to it.
Which types are handled now depends on how the module was configured, so
the opt-in check and root resolution moved from statics to per-module
state, and registering discards the cache of which hierarchy a type
belongs to, since a type that resolved to nothing may now resolve to a
root. A hierarchy's name table is derived from the class files alone and
is identical however the hierarchy opted in, so that stays globally
cached, and the deserializers take the resolved hierarchy rather than
looking it up.
getRegistrationId includes what was registered, since Jackson drops a
module whose registration id it has already seen and two modules
registering different types are not the same module.
Rebased onto main after the enum work landed separately in #2. Enum
handling here is identical to main's, including that a marked enum is
ignored rather than reported.
19 new tests; 95 total.
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.
Split out of #1 so it can land independently — this touches only enum handling and nothing to do with registration.
An enum permitted by a handled root was being written as a tagged object with a name per constant,
{"@type":"Status$IDLE"}, taking over a representation Jackson already has. Enums are now skipped everywhere: Jackson writes them as strings and this module does not interfere.{"signal":{"@type":"Data","value":1}} // the record is tagged {"signal":"IDLE"} // the enum is notPutting the marker on an enum is no longer an error either — it simply has no effect, since an enum is written as a string however it is declared.
Net deletion
Enum constants were the only thing a resolved name could be other than a class, so this removes more than it adds:
Subtypeand itssingletonfield,TypeTaggedEnumSerializer,TaggedEnumDeserializer, and both enum modifier hooks are gone. A name now resolves to aClassand nothing else.One consequence, worth a look in review
An enum member has no
@typename, so a value of one cannot be read back through the hierarchy's base type — a string is not something the base can dispatch on. Writing works; reading works wherever the property is declared as the enum type itself.Rather than surface that as a bare token mismatch, the failure now names the enums the hierarchy permits and points at the alternatives (declare the property as the enum type, or use a no-component record, which writes
{"@type":"Unknown"}and reads straight back). It is pinned by a test and documented in the README.If that asymmetry is unwanted, the fix would be to let the base deserializer accept a JSON string as naming an enum constant — read-side only, leaving Jackson's string output untouched. Not done here.
Tests
The enum tests are kept with corrected assertions.
EnumsUntouchedTestproves non-interference the strongest way available: it writes each value through a mapper carrying the module and through a plain one and asserts the two agree, which catches differences that were not predicted. It covers enums declared at the base type and at their own type, in collections, asMapkeys, with a custom@JsonValue, and with constant bodies — that last being implicitlysealedand abstract with each constant an anonymous subclass, so it reaches every sealed check the module makes.76 tests, passing on JDK 17, 21 and 25.
🤖 Generated with Claude Code
https://claude.ai/code/session_012hxYY9KPDAMXADWzjBHNV4