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Β to generate Java SDKs, this pattern is built in. Configure it globally in 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