SDKs
Java Unions with Jackson: Discriminated, Non-Discriminated, and Primitives
Vishal Gowda
January 22, 2026 - 8 min read
APIs evolve. New fields appear, new response types emerge. For simple enums, open enum patterns handle unknown values gracefully. But unions present a harder challenge: instead of a new value, the API might return an entirely new type that your code has never seen.
The Core Principle: Preserve What You Don’t Understand
Forward Compatibility Thesis
Never lose data. Never crash. When your code encounters an unknown variant, it should preserve the raw data for debugging and forwarding while allowing graceful degradation.
Both patterns we’ll explore (discriminated unions for object-only members, and non-discriminated unions for objects, primitives, or mixed types) implement this same principle. The specifics vary, but the guarantee is constant: your code won’t throw exceptions when the API evolves.
Two Patterns for Union Types
OpenAPI’s oneOf keyword describes unions, but the implementation strategy depends on whether a discriminator is present:
| Pattern | Java Implementation | Member Types | Jackson Mechanism |
|---|---|---|---|
| Discriminated | Inheritance (shared interface) | Objects only | @JsonTypeInfo + TypeIdResolver |
| Non-discriminated | Composition (wrapper class) | Objects, primitives, arrays | Custom OneOfDeserializer |
Why discriminated unions require objects
A discriminator is a property embedded within the JSON object (e.g., "type": "email"). Primitives like strings or integers have no properties, so you can’t attach a type field to the number 42. This is why discriminated unions are restricted to object schemas in both OpenAPI and our Java implementation.
Let’s work through each pattern.
Discriminated Unions
Discriminated unions have an explicit field (like type) that tells you which variant you’re dealing with:
Notification:
oneOf:
- $ref: '#/components/schemas/EmailNotification'
- $ref: '#/components/schemas/SmsNotification'
- $ref: '#/components/schemas/PushNotification'
discriminator:
propertyName: type
mapping:
email: '#/components/schemas/EmailNotification'
sms: '#/components/schemas/SmsNotification'
push: '#/components/schemas/PushNotification'The Pattern: Interface + TypeIdResolver
The pattern uses an interface with Jackson’s @JsonTypeInfo for polymorphic deserialization:
@JsonTypeInfo(
use = Id.CUSTOM,
property = "type",
include = As.EXISTING_PROPERTY,
visible = true,
defaultImpl = UnknownNotification.class // ← Forward compatibility
)
@JsonTypeIdResolver(NotificationTypeIdResolver.class)
public interface Notification {
String type();
}The key annotations:
use = Id.CUSTOM: We provide our own type resolverdefaultImpl = UnknownNotification.class: Unknown discriminator values don’t throw; they deserialize to this class
Interface limitation
All union members must implement the shared interface. You can’t “attach” an interface to an already-compiled third-party or JDK class from the outside. This is a fundamental Java constraint. If your union includes String, LocalDate, or other external types, you’ll need the non-discriminated wrapper pattern instead.
The TypeIdResolver maps discriminator values to concrete classes:
public class NotificationTypeIdResolver extends GenericTypeIdResolver<Notification> {
public NotificationTypeIdResolver() {
super(UnknownNotification.class);
}
private void initializeTypeMap() {
registerType("email", EmailNotification.class);
registerType("sms", SmsNotification.class);
registerType("push", PushNotification.class);
}
@Override
public String idFromValue(Object value) {
return value instanceof Notification n ? n.type() : null;
}
}When Jackson sees {"type": "slack", ...}, it looks up "slack", finds no match, and falls back to UnknownNotification.
The UnknownType Base Class
Unknown variants preserve the raw JSON for debugging and forwarding:
public class UnknownNotification extends UnknownType implements Notification {
@JsonCreator
public UnknownNotification(JsonNode rawNode) {
super(rawNode);
}
@Override
public String type() {
return extractDiscriminator("type").orElse("UNKNOWN");
}
}public class UnknownType {
@JsonValue
private final JsonNode raw;
public UnknownType(JsonNode rawNode) { this.raw = rawNode; }
protected Optional<String> extractDiscriminator(String key) {
return Optional.ofNullable(raw)
.filter(n -> n.has(key) && n.get(key).isTextual())
.map(n -> n.get(key).asText());
}
public JsonNode asJson() { return raw; }
}Consuming Discriminated Unions
Use instanceof to handle each variant:
// Java 11-16
Notification notification = response.notification();
if (notification instanceof EmailNotification) {
EmailNotification email = (EmailNotification) notification;
sendEmail(email.to(), email.subject(), email.body());
} else if (notification instanceof UnknownNotification) {
UnknownNotification unknown = (UnknownNotification) notification;
log.warn("Unknown notification type: {}", unknown.type());
forwardToFallback(unknown.asJson());
}// Java 17+: Pattern matching
if (notification instanceof EmailNotification email) {
sendEmail(email.to(), email.subject(), email.body());
} else if (notification instanceof UnknownNotification unknown) {
log.warn("Unknown notification type: {}", unknown.type());
}// Java 21+: Pattern matching in switch
switch (notification) {
case EmailNotification email -> sendEmail(email.to(), email.subject(), email.body());
case SmsNotification sms -> sendSms(sms.phoneNumber(), sms.message());
case UnknownNotification unknown -> {
log.warn("Unknown type: {}", unknown.type());
forwardToFallback(unknown.asJson());
}
default -> throw new IllegalStateException(); // Required without sealed types
}Non-Discriminated Unions
When there’s no discriminator field, Jackson can’t use polymorphic type handling. Instead, we need a wrapper class with a custom deserializer:
SearchQuery:
oneOf:
- $ref: '#/components/schemas/TextSearch'
- $ref: '#/components/schemas/FilterSearch'
- $ref: '#/components/schemas/VectorSearch'The Pattern: Wrapper + Custom Deserializer
The wrapper class holds one of the possible types (or raw JSON as fallback):
@JsonDeserialize(using = SearchQueryDeserializer.class)
public class SearchQuery {
@JsonValue
private final Object value; // TextSearch, FilterSearch, VectorSearch, or JsonNode
private SearchQuery(Object value) { this.value = value; }
// Factory methods for each variant
public static SearchQuery of(Object value) { return new SearchQuery(value); }
public static SearchQuery ofUnknown(JsonNode raw) { return new SearchQuery(raw); }
// Type-safe accessors
public Optional<TextSearch> textSearch() {
return value instanceof TextSearch t ? Optional.of(t) : Optional.empty();
}
public Optional<FilterSearch> filterSearch() {
return value instanceof FilterSearch f ? Optional.of(f) : Optional.empty();
}
public Optional<VectorSearch> vectorSearch() {
return value instanceof VectorSearch v ? Optional.of(v) : Optional.empty();
}
// Fallback for unknown variants
public Optional<JsonNode> asJson() {
return value instanceof JsonNode j ? Optional.of(j) : Optional.empty();
}
}How the Deserializer Works
The deserializer uses a try-each-and-fallback approach. Here’s the core logic:
public class SearchQueryDeserializer extends StdDeserializer<SearchQuery> {
private static final List<Class<?>> CANDIDATES = List.of(
TextSearch.class, FilterSearch.class, VectorSearch.class
);
@Override
public SearchQuery deserialize(JsonParser p, DeserializationContext ctxt)
throws IOException {
JsonNode node = p.getCodec().readTree(p);
for (Class<?> candidate : CANDIDATES) {
try {
Object value = mapper.treeToValue(node, candidate);
return SearchQuery.of(value);
} catch (DatabindException ignored) {
// This type didn't match, try the next
}
}
// No type matched. Preserve raw JSON for forward compatibility.
return SearchQuery.ofUnknown(node);
}
}The key insight: instead of failing on unknown data, we preserve it. Your code never crashes. It either deserializes to a known type or hands you the raw JSON for inspection.
Smart Matching: When Multiple Types Fit
Without a discriminator, Jackson might successfully deserialize the same JSON into multiple types. This happens when one type is a subset of another:
// TextSearch has: query, limit
// FilterSearch has: query, limit, filters
// This JSON matches BOTH types:
{"query": "hello", "limit": 10}A naive “first match wins” approach would return TextSearch, but that depends on the order types are tried. The deserializer uses a multi-level tie-breaking algorithm to select the best match:
| Priority | Factor | Rule |
|---|---|---|
| 1 | Matched fields | Higher is better. The type that maps more JSON fields to schema properties wins. |
| 2 | Inexact enum matches | Lower is better. Unknown enum values (handled via open enums) count against a candidate. |
| 3 | Unmatched schema fields | Lower is better. Schema fields not present in the JSON suggest the type is too specific. |
This three-level scoring ensures that:
- Types using more of the JSON payload are preferred
- Exact enum matches beat unknown values
- Simpler types win when the JSON is sparse
For the example above:
TextSearchmatches 2 fields, has 0 unmatched → strong candidateFilterSearchmatches 2 fields, has 1 unmatched (filtersabsent) → slightly weaker
Result: {"query": "hello", "limit": 10} → TextSearch, but {"query": "hello", "limit": 10, "filters": [...]} → FilterSearch.
Consuming Non-Discriminated Unions
Use the type-safe accessors:
SearchQuery query = response.query();
query.textSearch().ifPresent(text -> executeTextSearch(text));
query.filterSearch().ifPresent(filter -> executeFilterSearch(filter));
query.vectorSearch().ifPresent(vector -> executeVectorSearch(vector));
query.asJson().ifPresent(raw -> log.warn("Unknown query type: {}", raw));Primitive Unions
Primitives are a special case of non-discriminated unions. Since primitives have no properties, they can’t participate in discriminated unions. But they work seamlessly with the wrapper pattern. The same OneOfDeserializer handles them, discriminating by JSON structure rather than field matching:
ConfigValue:
oneOf:
- type: string
- type: integer
- type: boolean
- type: numberThe Pattern: Wrapper with Type-Specific Accessors
@JsonDeserialize(using = ConfigValue._Deserializer.class)
public class ConfigValue {
@JsonValue
private final TypedObject value;
public static ConfigValue of(String value) { /* ... */ }
public static ConfigValue of(long value) { /* ... */ }
public static ConfigValue of(boolean value) { /* ... */ }
public static ConfigValue of(double value) { /* ... */ }
public Optional<String> string() { /* ... */ }
public Optional<Long> asLong() { /* ... */ }
public Optional<Boolean> asBoolean() { /* ... */ }
public Optional<Double> asDouble() { /* ... */ }
public Optional<JsonNode> asJson() { /* ... */ } // Fallback
}The deserializer discriminates by JSON structure:
"hello"→ String (quoted)42→ Long (integer)3.14→ Double (decimal)true/false→ Boolean
Consuming Primitive Unions
ConfigValue config = response.config();
config.string().ifPresent(s -> applyStringConfig(s));
config.asLong().ifPresent(n -> applyNumericConfig(n));
config.asBoolean().ifPresent(b -> applyBooleanConfig(b));
config.asJson().ifPresent(raw -> log.warn("Unknown config type: {}", raw));Quick Reference
| Pattern | Unknown Handling | How to Check |
|---|---|---|
| Discriminated (objects) | UnknownXxx extends UnknownType | instanceof UnknownNotification |
| Non-discriminated (objects) | asJson() returns Optional<JsonNode> | query.asJson().isPresent() |
| Non-discriminated (primitives) | Same asJson() fallback | config.asJson().isPresent() |
Looking Ahead: Java 17+ and Beyond
Java 11 Baseline
The patterns above target Java 11 as the baseline. If you’re targeting Java 17+, unions become more elegant with sealed interfaces and records.
// Java 17+
public sealed interface Notification permits
EmailNotification, SmsNotification, PushNotification, UnknownNotification {
String type();
}
public record EmailNotification(String type, String to, String subject, String body)
implements Notification {}
public record UnknownNotification(String type, JsonNode raw)
implements Notification {}// Java 21+ pattern matching
switch (notification) {
case EmailNotification(var type, var to, var subject, var body) -> sendEmail(to, subject, body);
case SmsNotification(var type, var phone, var msg) -> sendSms(phone, msg);
case UnknownNotification(var type, var raw) -> log.warn("Unknown: {}", type);
}Exhaustiveness is enforced at compile time, so no default case is needed.
Generate Forward-Compatible Java SDKs with Speakeasy
If you’re building SDKs from OpenAPI specs, Speakeasy
New to Speakeasy? The patterns in this post are built into every Java SDK we generate. Your users get forward-compatible unions out of the box, with no additional configuration required for new projects.
Already using Speakeasy? Enable these patterns with the following configuration:
# gen.yaml
java:
openUnions: true # Forward-compatible union types
forwardCompatibleEnumsByDefault: true # Forward-compatible enums (see companion post)Or enable per-schema in your OpenAPI spec:
Notification:
oneOf: [...]
x-speakeasy-unknown-values: allowFor the full configuration reference, see Forward Compatibility in Speakeasy SDKs.
Related reading:
- Open Enums in Java: Class-based patterns for forward-compatible enums
- Forward Compatibility in Speakeasy SDKs: Full configuration reference