SDKs
Open Enums in Java: Why Classes Beat Native Enums for API Evolution
Vishal Gowda
January 22, 2026 - 5 min read
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.
What This Pattern Gives You
- Static constants (
ProductCategory.ELECTRONICS) that work like enum values - Reference equality via singleton caching in the
of()factory - Type-safe accessors via
asEnum()for switch statements - Unknown value preservation via the underlying
String value
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 gen.yaml:
java:
forwardCompatibleEnumsByDefault: true # recommended for response enumsOr enable it per-enum in your OpenAPI spec using x-speakeasy-unknown-values: allow.
Related reading:
- Java Unions with Jackson: Handling polymorphic responses with forward compatibility
- Forward Compatibility in Speakeasy SDKs: Full reference for all languages