I Like Enums, but I’ve Stopped Calling Them Closed
I really do like enums. They replace strings and integers that could mean anything with a small set of named values that the compiler understands. They make an API inside a codebase easier to discover, keep typos out of branches, and let an IDE show me the entire domain without sending me to documentation. When the set is genuinely finite, an enum feels like the language and the problem agreeing with each other. The trouble starts when the same values leave the codebase and become part of an API contract.
I used to ask a Java interview question about the best way to write a singleton. Most answers started with a private constructor and a static field, then accumulated synchronization, double-checked locking, or a holder class. The answer I was looking for was a single-element enum. It is concise, serialization cannot accidentally create a second instance, and reflection cannot invoke an enum constructor to manufacture another one. The JVM can provide those guarantees because an enum makes a strong promise: its instances were decided when the type was compiled.
I also like that Java enums are real classes, not dressed-up integers. They can carry fields and methods, implement
interfaces, and even give individual constants their own behavior.[16] EnumSet and EnumMap take advantage of the
same finite universe: one can be backed by a bit vector and the other by an array.[17] There is something satisfying
about the language using the closed set rather than merely asking me to remember it.
Modern Java switch expressions give me another reason to like them. Switch over an enum without a broad default, and
the compiler can tell you when you have forgotten a case. Add a new constant in the same build and every exhaustive
switch becomes a small to-do list. That is exactly the kind of work I want a type system to do for me.
All of these benefits come from the same assumption: the compiler knows the whole set.
It took me a while to appreciate how local that assumption is.
Inside one program, an enum is closed because one compiler owns both the definition and its uses. Put the same value on a network and the situation changes. The producer and consumer may have been generated from different schema versions, released by different teams and deployed months apart. I still find it useful to think of the enum as closed in my code, but once it crosses that boundary, it is really just the set of values known so far.
This matters most for response fields: statuses, categories, plan tiers, review outcomes, fulfillment states. A server accepting a new request value is usually additive for old clients; they simply will not send it. A server returning a new response value is different. Every existing consumer has to do something with a value that did not exist when it was built.
Consider a payment status:
pending
succeeded
failed
Sooner or later somebody needs disputed. The producer sees one new item in a schema. An old consumer may see a
deserialization error, an unknown placeholder, a missing UI element, or a branch of business logic that never runs.
That is what makes this easy to underestimate: one harmless-looking schema change can fail in several completely different ways.
An additive API change can still break consumers
OpenAPI makes enums attractive. They improve documentation and autocomplete, and code generators can turn them into Java or C# enums, TypeScript unions, or equivalent native types.
The generated behavior is not consistent across languages or generators. A TypeScript union may disappear entirely at runtime unless a validator enforces it. A generated Java client may reject an unknown value while parsing JSON. Other clients preserve the string or map it to a fallback. OpenAPI Generator and NSwag users have both reported clients failing on values absent from the schema they were generated from.[13][14]
OpenAPI Generator makes the compatibility problem unusually explicit. Its Java generator has an enumUnknownDefaultCase
option. The documentation says that, without it, a client can fail to parse a response containing a value added by a
newer server. The option is off by default and, when enabled, generates an unknown_default_open_api case.[1]
That option exists because synchronized upgrades are not a realistic assumption for a public API. You can ship
disputed today while an integration partner is still using an SDK generated six months ago. If its decoder is strict,
every webhook containing the new status may fail before the partner’s application code even sees it.
This argument has been going on for years, and I understand why. A GraphQL issue asking whether adding an enum member should count as a breaking change ended up circling the familiar disagreement: the schema change looks additive, but clients often write exhaustive handling based on the old schema.[2]
Stripe takes the operational view. One of its versioned changelog entries puts newly added enum values under “Breaking changes” and warns that integrations expecting the previous set may break.[3] I find that definition more useful than the theoretical one. If a change can make a previously working consumer reject a response or behave incorrectly, calling it additive does not help the person debugging it.
Getting the new value through the decoder is only the first problem. If parsing succeeds, the application still has to decide what that value means.
A catch-all branch is not always the answer
The advice I hear most often is to add a default or unknown branch everywhere. I used to think that was the whole
answer. It is not.
Within a codebase that owns the enum, exhaustive handling is useful. A Java switch expression over an enum must
cover every known constant. Leaving out default lets the compiler point at every place that needs a decision when a
new constant is added.
var action = switch (status) {
case PENDING -> HOLD_INVENTORY;
case CANCELLED -> RELEASE_INVENTORY;
case SHIPPED -> CLOSE_ORDER;
};
If the same build adds PARTIALLY_SHIPPED, this expression stops compiling until somebody decides what it means. A
broad default would hide that reminder.
An ordinary switch statement is different. Java permits an enum switch statement to omit cases, and a missing case can silently do nothing.[4]
switch(order.getStatus()){
case PENDING -> holdInventory(order);
case CANCELLED -> releaseInventory(order);
case SHIPPED -> closeOut(order);
}
If getStatus() can now produce PARTIALLY_SHIPPED, there may be no exception, log entry, or alert. Inventory just
remains held until somebody notices that warehouse numbers no longer reconcile.
Neither behavior solves the API-boundary problem. The compiler can only check the enum version in the current build. It cannot prove that another service will never send a fourth string tomorrow.
The compromise I prefer is to separate the wire value from the application’s closed model:
Known(PENDING)
Known(CANCELLED)
Known(SHIPPED)
Unknown("partially_shipped")
Preserve the raw unknown value. Then make Unknown an explicit member of the internal type and handle that type
exhaustively. This gives you both properties you want: new wire values survive decoding, while application code still
gets compiler help.
The unknown branch should also be boring and safe. I do not want it making a clever guess. A UI can display the raw label in a neutral style. Analytics can group it under “other” while retaining the original value. A fulfillment service may need to stop automatic processing and raise an operational signal rather than guessing that an unknown status means “pending.”
Unknown should mean “we do not know,” not “pick whichever existing value causes the fewest compiler errors.”
When unknown values fail quietly
A decoder exception is painful, but at least it is visible. The failures that worry me more are the ones that keep the process running.
A status-pill component may have a hardcoded color for every known value and return nothing for the fallback. The order still exists, but the UI makes it appear incomplete. A reporting pipeline may discard rows whose category is not in a dimension table. A webhook handler may acknowledge an event without applying any state transition.
The new value can survive HTTP, JSON parsing and object construction, then disappear at the first assumption of exhaustiveness.
This is why merely adding an UNKNOWN constant is not sufficient. You also need to decide what unknown means at each
use:
- Can the operation continue safely?
- Should the value be shown to a user?
- Should it be stored and replayed later?
- Is an alert useful, or will it create noise during every planned rollout?
- Is the raw value available for diagnosis?
I do not think there is a universal fallback behavior. There should at least be a deliberate one.
The database has its own evolution problem
So far I have followed the enum into the consumer. The same business status usually exists on the producer side too, and
often ends up in a database where it is tempting to represent it as a native PostgreSQL ENUM.
Native enums have real advantages. They reject invalid values, document the domain and give the database a proper type instead of a string convention. For a genuinely static set, I am quite happy to use one.
PostgreSQL’s own documentation describes enum types as primarily intended for static sets.[5] You can add and rename values, but the evolution model is intentionally narrow.
One piece of old advice is still repeated incorrectly: on PostgreSQL 12 and later, ALTER TYPE ... ADD VALUE can
run inside a transaction. The new value cannot be used until that transaction commits.[6] This distinction matters if a
migration both adds the value and immediately updates rows to use it. Older PostgreSQL versions rejected ADD VALUE in
a transaction block, which is why historical migration-tool issues and workarounds say otherwise.[7]
Removal remains awkward. PostgreSQL has no ALTER TYPE ... DROP VALUE. If a value must disappear, the usual repair is
to create a replacement enum, convert dependent columns, and drop the old type, taking defaults and other dependencies
into account.[8]
I do not want to overcorrect here and claim that varchar is automatically better. A check constraint is also schema,
and changing it can involve validation, locking, and migration planning. A lookup table introduces joins and its own
integrity questions. The choice depends on how the values behave:
- A small set tied to the application release cycle can fit a native enum.
- A product-managed taxonomy that changes frequently is usually easier as data in a lookup table.
- A string plus a check constraint can be a useful middle ground when database validation matters but replacing the allowed set should remain straightforward.
The mistake is not using a PostgreSQL enum. It is using one for a domain that the product treats like editable content.
Protobuf preserves unknown values, not their meaning
PostgreSQL turns enum evolution into a schema problem. On the wire, protobuf takes a different approach, and I think it gets more right than most formats.
In proto3, the first enum value must have number zero. The usual convention is a name such as STATUS_UNSPECIFIED, so
an absent field has a meaningful default. Proto3 enums are open: an unrecognized numeric value is preserved when a
message is parsed and serialized again.[9]
How application code sees the value depends on the language. In generated Java code, an enum getter returns the
synthetic UNRECOGNIZED value, while the corresponding numeric accessor returns the original integer.[10] C++ and Go
can expose the unknown integer directly. Closed enums and some cross-language and library edge cases behave differently,
which is worth checking if proto2, mixed editions, or a non-Google implementation such as protobuf.js is
involved.[11][15]
This is much better than rejecting the whole message. I would choose it over a hard failure in most systems. It still solves only the transport part of the problem.
An old binary receiving a new value can carry that value without destroying it, but it still does not know whether the value means “release inventory,” “wait for another event,” or “escalate to a person.” Forward-compatible serialization is not forward-compatible business logic.
There is another protobuf rule that belongs in the same conversation: when removing an enum value, reserve both its number and its name. Reusing either can corrupt meaning when old serialized data or old binaries are still around.[12]
Protobuf gives you a safe unknown-value channel. You still have to design the behavior at the end of that channel.
Test a value that does not exist yet
Contract tests normally prove that values currently listed in the schema round-trip correctly. That is necessary, but it misses the compatibility question. The test I want to see is almost embarrassingly small.
For every response enum likely to evolve, send the consumer a value absent from its schema:
{
"status": "future_status"
}
Then follow the value farther than the decoder:
- Does deserialization succeed?
- Is the raw value preserved?
- Does business logic choose a safe path?
- Does the UI render something intelligible?
- Do logs or metrics make the event diagnosable?
- Can the object be stored and re-serialized without losing information?
Run this test against the actual generated SDK and its real configuration. Code-generator behavior varies enough that testing a hand-written model is not a substitute.
For webhooks, also test the delivery behavior. If an unknown enum makes the handler return a 500, a harmless additive rollout can turn into a retry storm. It may be safer to retain the event, surface the unknown state, and acknowledge delivery—provided doing so does not lose a required business action.
I would not even call this fuzzing in the broad sense. It is one carefully chosen mutation with a high probability of becoming real.
Design for the set you know so far
None of this has made me stop using enums. It has made me more careful about what I think the enum is promising. The useful distinction is between a closed set inside an ownership boundary and a set that only appears closed because a schema recorded what existed at one point in time.
The practices that have held up best for me are:
- Treat response enums controlled by another deployment as open at the decoding boundary.
- Preserve unknown raw values instead of coercing them to an existing business state.
- Use exhaustive switches for internal enums you own; do not add a broad default merely to silence the compiler.
- Represent the external unknown case explicitly, then handle that internal type exhaustively.
- Test an unknown value through parsing, business logic, persistence and presentation.
- Add observability for unknown values, but avoid unbounded metric labels if arbitrary strings can arrive.
- Check the behavior of your exact generator, serializer and language rather than assuming all clients behave alike.
- Use native database enums for domains that are actually managed like static types, not lists the product team expects to edit.
- In protobuf, handle
UNRECOGNIZEDdeliberately and reserve the numbers and names of deleted values.
Sometimes the right API schema is not an enum at all. If the server intends to introduce values freely and clients only display them, documenting known examples as strings may be more honest than promising a closed set. If clients need logic for each value, an enum is useful—but the API needs a compatibility policy and, ideally, versioning that makes new values predictable.
The enum itself was never really the problem. The problem was my closed-world assumption surviving a trip across a boundary where nobody controls the whole world.
An enum in source code describes the values the compiler knows. An enum in an API describes the values the producer knew when the schema was published. Those statements look almost identical in YAML. They offer very different guarantees.
References
- OpenAPI Generator, Java generator option
enumUnknownDefaultCase. - graphql/graphql-js, “Adding an enum value is breaking change?”.
- Stripe Changelog, “Adds additional enum values for Radar manual reviews”.
- Oracle, Java Language Specification §14.11: The
switchStatement and Switch Expressions and Statements. - PostgreSQL, Enumerated Types.
- PostgreSQL,
ALTER TYPE. - TypeORM, Historical PostgreSQL migration transaction issue.
- yo1.dog, “Updating Enum Values in PostgreSQL”.
- Protocol Buffers, Language Guide (proto3): Enumerations.
- Protocol Buffers, Java Generated Code Guide: Enumerations.
- Protocol Buffers, Enum Behavior.
- Protocol Buffers, Proto Best Practices.
- OpenAPITools/openapi-generator, Client behavior for unknown enum values.
- Filip Kovář, “How to fix NSwag API client unknown enum value error”.
- protobufjs/protobuf.js, Unknown enum deserialization behavior.
- Oracle, Enum Types and Java Language Specification §8.9: Enum Classes.
- Oracle,
EnumSetandEnumMap.