Your service must expose a closed three-variant payment type across an interface that carries only records of optional fields — what do you lose, and how do you keep the choice recoverable?
answer
- the interface cannot carry a choice
- flattening multiplies the shapes admitted
- the guarantee moves, it does not vanish
- explicit tag, not inference from presence
- decode at ingress, encode at egress
basics
~20 sYou lose the guarantee that the shape carries: the flattened record permits combinations the closed choice cannot express. Carry an explicit tag field naming the variant, rebuild the choice on the way in, and treat the flat record as a transport shape only.
solid answer
~40 sFlattening turns a sum into a product, and the inhabitant count explodes: three shapes become a tag plus several optional payload fields, so the transport shape admits mixtures the model forbids. The guarantee does not disappear, it **moves** — from something the type holds everywhere to a check performed once, where the record is decoded. Keep the choice recoverable by carrying an explicit tag naming the variant rather than letting readers infer it from which fields are present, decoding into the closed choice immediately on ingress, and flattening again only at the last moment on the way out. What you pay is a boundary that can be wrong, plus a decision to document for the ambiguous cases: an unrecognised tag, a missing tag, and a tag that disagrees with the fields.
code
pseudocode · 15 lines// transport shape: what the interface can carry
type PaymentWire is a record of:
kind: text // "card", "bank" or "credit"
cardNumber: text or absent
accountRef: text or absent
voucherId: text or absent
function decodePayment(wire):
if wire.kind = "card" and wire.cardNumber is present:
return Ok(Card(wire.cardNumber)) // stray fields ignored: tag wins
if wire.kind = "bank" and wire.accountRef is present:
return Ok(BankTransfer(wire.accountRef))
if wire.kind = "credit" and wire.voucherId is present:
return Ok(StoreCredit(wire.voucherId))
return Rejected("unknown tag or payload missing for tag " + wire.kind)go deeper
The takeaway is that the shape you send is allowed to differ from the shape you model. Send a field that names the kind, and turn it back into the proper shape as soon as the data arrives.
Explain the mechanics: an explicit tag beats inferring from field presence, decoding happens once at ingress, and the flattened record permits many more shapes than the three the model has.
Show where the guarantee went and what that costs in production: one decoder that can be wrong, a documented rule for a tag that disagrees with its payload, and an unknown tag handled deliberately rather than ignored.
Treat the transport shape as a versioned contract with its own lifecycle, and set the rule once for every service: model as a closed choice, flatten only at the edge, and roll out a new variant on the consumer side before producers may send it.
Every closed choice eventually meets an interface that has no notion of one, and the interesting part is not the encoding but where the guarantee ends up living. ## What flattening actually does A closed choice of three variants admits three shapes. Flatten it into a record with a tag plus three optional payload groups and the transport shape admits the tag's values multiplied by every presence combination of the payload fields — a number in the dozens rather than three. The extra shapes are not hypothetical: a producer with a bug, an older producer, or a hand-written request will eventually send one. So the honest description of the flattening is: **the model's shape is no longer enforced by the shape of the data.** That does not make the design wrong. It relocates the enforcement. ## The tagged encoding 1. **Carry an explicit tag.** A field whose value names the variant — not a flag, and not "whichever payload field is present". An explicit tag survives a producer sending an extra field, and it gives a reader one thing to look at rather than a rule to apply. 2. **Group the payload per variant.** Either nest each variant's fields under their own object, or name them so the grouping is visible. Six flat, similarly named optional fields invite a producer to fill the wrong one. 3. **Decode at ingress, encode at egress.** Rebuild the closed choice as early as the data arrives, and flatten as late as it leaves. Everything between those two points works with three shapes. ## Where the guarantee now lives | Layer | What the shape permits | Who enforces it | |---|---|---| | Interior model | three shapes | the type, everywhere it is used | | Decoder at the boundary | the transport shapes on the way in | one function, at run time | | Transport record | tag values times payload combinations | nobody; whatever arrives, arrives | The row that matters is the middle one. A check that used to be structural and everywhere is now procedural and in one place — which is a fair trade *if* it really is one place. The failure mode is a system that decodes partially in three different services and passes the flat record inward, at which point every function re-derives the rule and they drift apart. ## The seams that need a decided rule - **An unrecognised tag.** A new variant from a newer producer. Reject, or route to a quarantine path — but decide, because the default of ignoring it silently loses payments. - **A missing tag.** Simply incomplete; reject it, and say so in the error rather than guessing from the fields. - **A tag that disagrees with the payload** — the tag says card and the voucher identifier is also present. This is the one that needs an explicit rule. Letting the tag win and ignoring stray fields is simple and keeps the decoder total, but it silently discards data a producer believed it sent. Rejecting surfaces the producer's bug at the cost of a stricter contract. Either is defensible; leaving it undecided means each decoder picks differently. - **A tag that matches but with an incomplete payload** — card without an expiry. This is a plain validation failure, and the decoder should say which field was missing. ## What you actually lose - The check moves from **compile time inside the model** to **run time at the edge**, so a mistake in the decoder is a production failure rather than a build failure. - The transport shape becomes its own thing to version and document, separate from the model, and the two can drift. - Adding a variant now touches both the model and the encoding, and there is a window where producers and consumers disagree about the tag set. What you keep is worth the trade: the interior of the service never reasons about sixty-odd possible record shapes, because everything past the decoder is one of three. ## What an interviewer is listening for They want to hear the relocation stated plainly — the guarantee moved, it did not vanish — and then the concrete mechanics: an explicit tag rather than inference from field presence, decode at the edge, and a named decision for the tag-disagrees-with-payload case. A candidate who only says "add a type field" has the encoding but not the argument; a candidate who says the closed choice is impossible across such an interface has missed that the model and the transport shape are allowed to differ.
- The tag says card but the account reference is also present — what should the decoder do?Pick one rule and write it down. Tag-wins-and-ignore keeps the decoder total and simple, but silently drops data the producer believed it sent. Rejecting surfaces the producer's bug at the cost of a stricter contract. The defect is leaving it undecided, because separate decoders then choose differently and the same request succeeds in one service and fails in another.
- Why decode at the edge rather than passing the flat record further in?Because every interior function would otherwise re-derive the rule, and each copy is a chance to disagree. Decoding once means the interior sees three shapes instead of the transport record's dozens, and a malformed request fails at the boundary with a message about the request rather than deep inside business logic with a message about a missing field.
- Should the tag be inferred from which payload field is present instead of sent explicitly?Prefer the explicit tag. Inference breaks the moment a producer sends an extra or stale field, and it makes two variants with similar payloads indistinguishable. An explicit tag also lets the decoder reject an unknown kind as a clear error rather than falling through to a guess.
saying these in an interview costs you the question
- Says the closed choice cannot survive such an interface at all
- Infers the variant from which payload field happens to be present
- Passes the flat transport record deep into the system undecoded
- Trusts the producer instead of decoding and validating at the boundary
- Leaves the tag-disagrees-with-payload case undecided across services
- Claims a run-time check at the edge is equivalent to the model's own shape