skip to content

What happens when a writer starts sending an enumerated value that a reader on the earlier schema has no name for?

level: seniorimportance: nice to knowfreq 28%

answer

  1. additive in the schema only
  2. the decoder is rarely the casualty
  3. a closed branch is the trap
  4. declare an unspecified member first
  5. retire members, never recycle numbers

basics

~20 s

The value usually arrives intact, so the decode is not where it breaks. It breaks in application logic that branches over the members it knows and has no arm for this one — an additive schema edit that is not additive for consumers.

solid answer

~60 s

Adding a member to an enumerated type looks like the safest edit there is: nothing is removed, nothing is renumbered. On the wire that is broadly true — families differ, but most carry the underlying number through to a reader that cannot name it, mapping it to a designated unknown member or exposing the raw value; some refuse the decode outright, which at least fails loudly. The hazard is one layer up. Consumer code written as an exhaustive branch over the members that existed when it was built now meets a value with no arm: it takes a fallback branch that was never designed for real traffic, or it throws in the middle of business logic rather than at the boundary. The contract-level fix is to declare an `unspecified` member from the first version and treat the member set as **open** — a branch over it must always have a default arm. Note this is not the same as an unknown *field*: the field is known and declared, and it is the value inside it that is new.

go deeper

for a junior

Recall that an enumerated field's set of values can grow after your code is written, so a branch covering every value you know about is a branch that can be surprised.

for a middle

Explain the two layers: the decoder usually carries the value through in some form, while the consumer's case analysis is what actually lacks an arm for it — and say that families differ about the decode.

for a senior

Show where the incident surfaces. It is not a parse error at the boundary but a fallback path or an exception deep in business logic, which is why the remedy is a designated unspecified member rather than a patch at the decode site.

for a principal

State the contract rule and hold the line on it: the member set is open, readers must tolerate growth, and an exhaustive branch over a growing set is a review finding — cheaper as a standing rule than as an incident.

## An additive edit that is not additive Adding a member to an enumerated type removes nothing, renumbers nothing and changes no existing field. By the usual test it should be safe, and at the byte level it mostly is. The reason it still appears on every list of dangerous edits is that an enumerated field's contract has two layers, and only one of them is in the schema: - **The wire layer**, which carries a number or a token, and asks the reader only to represent it. - **The logic layer**, where a consumer decides what to *do* per member — and where code is usually written as a closed set of cases. An additive schema edit widens the set. Code that assumed the set was closed does not widen with it. ## What the reader does with a member it cannot name Behaviour differs by encoding family, and a candidate who states one behaviour as universal is describing one ecosystem: | How the family treats an unnameable member | What the consumer sees | Where it breaks | |---|---|---| | Maps it to a designated unknown or default member | a well-formed value meaning "not one of mine" | a branch with no arm for that member | | Preserves the underlying number without a label | a raw value the code cannot match on | comparisons and lookups keyed on named members | | Refuses the decode | a boundary failure | early and loud — the least damaging outcome | The first two rows are the common ones, and both share a property: the failure surfaces far from the decode, in code that looks unrelated to serialization. That is what makes this edit a differentiator question — the person who has lived through it names application logic, not the parser. It is worth separating this from a neighbouring situation it is often confused with. An unknown **field** is an entry whose identifier the reader has no declaration for. An unknown **member** sits inside a field the reader knows perfectly well, with a value it cannot name. Different layer, different remedy. ## Designing the enumerated type so growth is cheap 1. **Declare an explicit unspecified member in the first version**, and make it the value a reader lands on when it has nothing better. It gives every consumer a defined destination and, more importantly, forces the branch to have a fallback arm from day one, when writing one is free. 2. **Treat the member set as open in the contract itself.** "New members may be added at any time and readers must tolerate them" is a statement about the contract, not about the code, and it is the sentence that justifies rejecting an exhaustive branch in review. 3. **Make the fallback do something defensible.** Route to manual handling, reject with a clear reason, or pass through unchanged — but not "treat as the first member", which is how a new payment or status type silently becomes an old one. 4. **Never renumber or reuse a member's number.** Members carry the same identifier discipline as fields: retire, never recycle, or old readers will map a new member onto a retired one's meaning. ## Removing a member is not the mirror image Adding is additive at the wire and breaking in logic. Removing looks like the reverse but is worse in one specific way: the removed value can still arrive — from writers not yet upgraded, and from stored messages that will be replayed for as long as the data is retained. The reader that no longer declares the member is now in exactly the position of an old reader meeting a new one, except the gap was self-inflicted and there is no upgrade that closes it, because the offending messages already exist. So a member is retired and its number reserved; it is not deleted. ## What good sounds like in an interview A strong answer moves in three beats: the decode usually survives and families differ about how; the break is a closed branch in business logic; and the remedy is a contract-level one — a designated unspecified member and an open-set rule — rather than a code-level `default:` bolted on after the incident. A weak answer stops at "it is additive, so it is fine", which is true of the bytes and false of the system.

  • What makes a designated unspecified member, declared in the first version, worth its cost?
    It gives every reader a defined place to land and forces every branch to carry a fallback arm at the moment that costs nothing. Added later, it fixes only consumers rebuilt afterwards — the ones already deployed still meet the new member with a closed branch, which is exactly the population you were trying to protect.
  • Is removing a member the mirror image of adding one?
    No, it is worse in one respect. The removed value can still arrive from writers not yet upgraded and from stored messages replayed later, and no upgrade closes that gap because the offending data already exists. So a member is retired with its number reserved, exactly as a field is, rather than deleted.

saying these in an interview costs you the question

  • Says adding a member is always safe because it is additive
  • Assumes every family maps an unknown member to a sentinel
  • Writes an exhaustive branch over a set expected to grow
  • Confuses an unknown member with an unknown field
  • Deletes a member instead of retiring it and reserving its number
  • Falls back to the first member when the value is unrecognised