Skip to content

Leave Java enums entirely to Jackson - #2

Merged
pjfanning merged 1 commit into
mainfrom
no-enum-handling
Aug 23, 2026
Merged

pjfanning merged 1 commit into
mainfrom
no-enum-handling

Conversation

@pjfanning

Copy link
Copy Markdown
Owner

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.

public sealed interface Signal extends SealedPolymorphismSupport permits Data, Status {}
public record Data(int value) implements Signal {}
public enum Status implements Signal { IDLE, BUSY }
{"signal":{"@type":"Data","value":1}}   // the record is tagged
{"signal":"IDLE"}                       // the enum is not

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.

Net deletion

Enum constants were the only thing a resolved name could be other than a class, so this removes more than it adds: Subtype and its singleton field, TypeTaggedEnumSerializer, TaggedEnumDeserializer, and both enum modifier hooks are gone. A name now resolves to a Class and nothing else.

One consequence, worth a look in review

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; 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. EnumsUntouchedTest proves 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, 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, passing on JDK 17, 21 and 25.

🤖 Generated with Claude Code

https://claude.ai/code/session_012hxYY9KPDAMXADWzjBHNV4

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
pjfanning merged commit fb5311d into main Aug 23, 2026
4 checks passed
@pjfanning
pjfanning deleted the no-enum-handling branch August 23, 2026 19:12
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
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