Skip to content

Support registering sealed types without the marker interface - #1

Open
pjfanning wants to merge 1 commit into
mainfrom
register-sealed-types
Open

pjfanning wants to merge 1 commit into
mainfrom
register-sealed-types

Conversation

@pjfanning

@pjfanning pjfanning commented Aug 23, 2026 •

Copy link
Copy Markdown
Owner

Rebased onto main after the enum work landed separately in #2. This PR now contains only the registration feature — git diff main on the enum-behaviour files (SealedHierarchy, Fixtures, InvalidHierarchyTest) is empty.

Opting in means 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 with the module instead.

SealedPolymorphismModule module = new SealedPolymorphismModule()
        .registerSealedInterfaceOrClass(Animal.class)
        .registerSealedInterfaceOrClass(Shape.class);

ObjectMapper mapper = JsonMapper.builder().addModule(module).build();

registerSealedInterfaceOrClass takes one type and can be called 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 @type names, same reading, same @JsonTypeInfo conflict rules.

What registration rejects

Rejected by registerSealedInterfaceOrClass itself, rather than later when Jackson first meets the type:

Argument Result
null error
not sealed error — registration replaces the marker, not the closed-hierarchy requirement
an enum error — enums are left to Jackson; register the sealed interface it implements instead
carries @JsonTypeInfo error — that already tells Jackson how to write and read the hierarchy

The @JsonTypeInfo case is worth a note. On a marked hierarchy that annotation means "leave this to Jackson" and the module stands down. But registering is an explicit act, so asking for both is a contradiction rather than something to resolve silently — it would ask for two type properties on the same object.

Nothing is relaxed below the root either: a non-sealed member of a registered hierarchy is still reported.

Other behaviour

  • 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 and its siblings are left alone.
  • A hierarchy that is not registered stays untouched — verified against the same fixtures through a module that was not told about them.
  • Enum handling is exactly main's, including that a marked enum is ignored rather than reported.

Implementation

Which types are handled depends on module configuration, so the opt-in check and root resolution moved from statics to per-module state. Roots can be added after the module exists, so the registry and the root cache are mutable; registering discards that cache, since a type that previously 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. The deserializers now take the resolved hierarchy rather than looking it up, so they no longer need to know how it opted in.

getRegistrationId includes what was registered — Jackson drops a module whose registration id it has already seen, and two modules registering different types are not the same module.

Tests

19 new tests — 18 in RegisteredTypeTest against a new UnmarkedFixtures holding ordinary sealed hierarchies that do not reference this module at all, plus one in EnumsUntouchedTest covering an enum in a registered hierarchy. 95 total, passing on JDK 17, 21 and 25.

🤖 Generated with Claude Code

https://claude.ai/code/session_012hxYY9KPDAMXADWzjBHNV4

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