Speakeasy Logo
Skip to Content

SDKs

Java Unions with Jackson: Discriminated, Non-Discriminated, and Primitives

Vishal Gowda

Vishal Gowda

January 22, 2026 - 8 min read

SDKs

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

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:

PatternJava ImplementationMember TypesJackson Mechanism
DiscriminatedInheritance (shared interface)Objects only@JsonTypeInfo + TypeIdResolver
Non-discriminatedComposition (wrapper class)Objects, primitives, arraysCustom OneOfDeserializer

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 resolver
  • defaultImpl = UnknownNotification.class: Unknown discriminator values don’t throw; they deserialize to this class

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:

PriorityFactorRule
1Matched fieldsHigher is better. The type that maps more JSON fields to schema properties wins.
2Inexact enum matchesLower is better. Unknown enum values (handled via open enums) count against a candidate.
3Unmatched schema fieldsLower 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:

  • TextSearch matches 2 fields, has 0 unmatched → strong candidate
  • FilterSearch matches 2 fields, has 1 unmatched (filters absent) → 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: number

The 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

PatternUnknown HandlingHow to Check
Discriminated (objects)UnknownXxx extends UnknownTypeinstanceof UnknownNotification
Non-discriminated (objects)asJson() returns Optional<JsonNode>query.asJson().isPresent()
Non-discriminated (primitives)Same asJson() fallbackconfig.asJson().isPresent()

Looking Ahead: Java 17+ and Beyond

// 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  generates all of these patterns automatically.

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: allow

For the full configuration reference, see Forward Compatibility in Speakeasy SDKs.


Related reading:

Last updated on

Build with
confidence.

Ship what's next.