Speakeasy Logo
Skip to Content

SDKs

Open Enums in Java: Why Classes Beat Native Enums for API Evolution

Vishal Gowda

Vishal Gowda

January 22, 2026 - 5 min read

SDKs

If you consume APIs that evolve, you’ve probably hit this: the server adds a new enum value, deserialization fails, and your service goes down at 3am. Java’s native enum is fundamentally closed. You can’t extend it at runtime, and you can’t add an Unrecognized<T> variant like you can with TypeScript’s union types.

This post presents a class-based “open enum” pattern that preserves type safety, enables exhaustive switch statements, and gracefully handles values your code has never seen.

The Core Problem: You Don’t Control Responses

Java enums are compile-time constants. That’s their strength for request parameters: you want the compiler to catch invalid values before they become 400 errors. But for responses, it’s a trap.

public enum OrderStatus { PENDING, PROCESSING, SHIPPED, DELIVERED }

You control what you send. You don’t control what you receive. When the server adds "on_hold" to the API, Jackson throws:

com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `OrderStatus` from String "on_hold": not one of the values accepted for Enum class: [PENDING, PROCESSING, SHIPPED, DELIVERED]

Your entire response fails to parse. Not just the status field, everything.

The Failed Alternatives

The obvious fix is to replace the enum with a String. But now every comparison is a typo waiting to happen, and you’ve lost all type safety.

What about configuring Jackson to deserialize unknown values as null? Now you have null checks everywhere, and you can’t distinguish “the field was null” from “the field had a value we don’t recognize.” That’s a debugging nightmare.

You could catch the exception and ignore the field entirely. But then you’ve lost the data. If the unknown value is actually important (say, a new status that requires special handling) your code silently does the wrong thing.

None of these preserve both the value and type safety. We needed a new pattern.

Building the Open Enum Pattern

Let’s build the solution piece by piece. Say we have a ProductCategory with known values: electronics, clothing, books, home_garden, and sports.

Step 1: A Class Wrapper That Never Crashes

First, we wrap the string value in a class. Jackson’s @JsonCreator and @JsonValue handle serialization:

public class ProductCategory { private final String value; private ProductCategory(String value) { this.value = value; } @JsonCreator public static ProductCategory of(String value) { return new ProductCategory(value); } @JsonValue public String value() { return value; } }

Now "furniture" deserializes successfully. It becomes ProductCategory.of("furniture"). No exception. The unknown value is preserved.

Step 2: Static Constants for Ergonomics

But we’ve lost the nice enum syntax. Let’s bring it back with static constants:

public class ProductCategory { public static final ProductCategory ELECTRONICS = new ProductCategory("electronics"); public static final ProductCategory CLOTHING = new ProductCategory("clothing"); public static final ProductCategory BOOKS = new ProductCategory("books"); public static final ProductCategory HOME_GARDEN = new ProductCategory("home_garden"); public static final ProductCategory SPORTS = new ProductCategory("sports"); // ... rest of the class }

Now you can write ProductCategory.ELECTRONICS just like a native enum. But there’s a catch: == comparison won’t work because each deserialization creates a new instance.

Step 3: Singleton Caching for Reference Equality

We add a cache so that known values always return the same instance:

private static final Map<String, ProductCategory> values = new HashMap<>(Map.of( "electronics", ELECTRONICS, "clothing", CLOTHING, "books", BOOKS, "home_garden", HOME_GARDEN, "sports", SPORTS )); @JsonCreator public static ProductCategory of(String value) { return values.computeIfAbsent(value, v -> new ProductCategory(v)); }

Now category == ProductCategory.ELECTRONICS works correctly.

Step 4: An Inner Enum for Exhaustive Switches

We’ve preserved the value. We’ve got ergonomic constants. But how do you write an exhaustive switch? The compiler can’t enforce exhaustiveness on arbitrary class instances.

The solution: an inner enum that mirrors the known values:

public enum ProductCategoryEnum { ELECTRONICS("electronics"), CLOTHING("clothing"), BOOKS("books"), HOME_GARDEN("home_garden"), SPORTS("sports"); private final String value; ProductCategoryEnum(String value) { this.value = value; } } private static final Map<String, ProductCategoryEnum> enumMap = Map.of( "electronics", ProductCategoryEnum.ELECTRONICS, "clothing", ProductCategoryEnum.CLOTHING, "books", ProductCategoryEnum.BOOKS, "home_garden", ProductCategoryEnum.HOME_GARDEN, "sports", ProductCategoryEnum.SPORTS ); public Optional<ProductCategoryEnum> asEnum() { return Optional.ofNullable(enumMap.get(value)); } public boolean isKnown() { return asEnum().isPresent(); }

The inner enum gives you compile-time exhaustiveness. The Optional return forces you to handle the unknown case.

Consumption Patterns

Here’s how you use open enums in practice:

Safety Check + Exhaustive Switch

ProductCategory category = product.category(); if (category.isKnown()) { switch (category.asEnum().get()) { case ELECTRONICS -> applyElectronicsDiscount(); case CLOTHING -> applyClothingDiscount(); case BOOKS -> applyBooksDiscount(); case HOME_GARDEN -> applyHomeDiscount(); case SPORTS -> applySportsDiscount(); // Compiler warns if you miss a case } } else { log.warn("Unknown product category: {}", category.value()); applyDefaultDiscount(); }

Reference Equality

if (category == ProductCategory.ELECTRONICS) { // Works because of singleton caching }

Functional Style

category.asEnum().ifPresent(known -> { String icon = switch (known) { case ELECTRONICS -> "🔌"; case CLOTHING -> "👕"; case BOOKS -> "📚"; case HOME_GARDEN -> "🏡"; case SPORTS -> ""; }; });

Using This Pattern with Speakeasy

If you use Speakeasy  to generate Java SDKs, this pattern is built in. Configure it globally in gen.yaml:

java: forwardCompatibleEnumsByDefault: true # recommended for response enums

Or enable it per-enum in your OpenAPI spec using x-speakeasy-unknown-values: allow.


Related reading:

Last updated on

Build with
confidence.

Ship what's next.