How does a shared serializer round-trip a field whose declared type is abstract, and what must be configured?
answer
- writing knows more than reading
- a value that names the shape
- registry of permitted subtypes
- placement and unknown-value policy
basics
~20 sWriting works because the runtime type is in hand. Reading does not: only the declared type is known, so the document must carry a discriminator value that a configured registry maps to exactly one permitted concrete type.
solid answer
~40 sConfigure four things on the shared instance: the discriminator property name, an explicit registry pairing each permitted subtype with a stable string value, where the discriminator sits (a key beside the payload's own members, a wrapper object keyed by the value, or a pair in an array), and what the reader does with a value it does not recognise. Keep the values contract vocabulary rather than code identifiers, so moving or renaming a type is not a wire change, and keep the registry an explicit allow-list rather than letting a document name a type to construct. Nesting follows the declared type at each position, so every element of a collection carries its own discriminator — the element type only tells the reader which registry to consult.
go deeper
Know that a field declared as an abstract type needs the document itself to say which concrete shape it holds, because reading cannot recover what writing knew.
Explain the configuration: a discriminator property name, an explicit registry of permitted subtypes with stable values, the placement form, and the policy for values the reader does not recognise.
Show judgment on rollout and tolerance — discriminator values decoupled from code identifiers, an allow-list registry, and the ordering that lets producers add a kind without breaking existing readers.
Decide whether polymorphic payloads belong in your contracts at all, and if so, fix one discriminator convention across the estate so schemas, clients and tooling do not each learn a different one.
## The asymmetry that makes a discriminator necessary Serializing a value whose declared type is abstract is easy: at write time the serializer has the actual object in hand and can inspect its real type. Reading is not symmetric. At read time the serializer has a document and a *declared* target type; the concrete type it should build is exactly the information that has been lost. Nothing in the document says which one it is unless the contract puts it there. So a polymorphic payload must carry a **discriminator**: a value inside the document that the reader maps onto exactly one concrete type from a configured registry. This is not an optimisation, it is the only in-band channel available. ## What has to be configured on the shared instance 1. **The discriminator property name** — one name, used across the estate, so readers and schema tooling do not have to learn a new one per model. 2. **The registry of permitted subtypes** — an explicit list pairing each concrete type with a stable string value. Keep the list an allow-list; never let the document itself name a type to construct, both because it couples the wire to your code layout and because the security consequences of an open registry are a subject of their own. 3. **The placement form** — where the discriminator sits relative to the payload's own members. 4. **The behaviour for an unrecognised value** — reject, or resolve to a designated catch-all subtype. 5. **Application to nesting** — collections and nested members are governed by the declared type at each position, so *each element* of a list carries its own discriminator; the element type only tells the reader which registry to consult. | Placement | Shape | Trade-off | |---|---|---| | Sibling key | `{"kind":"card","last4":"4242"}` | flattest and most readable; collides if the payload already has that key | | Wrapper object | `{"card":{"last4":"4242"}}` | no collision, but every payload gains a level and schemas get noisier | | Pair in an array | `["card",{"last4":"4242"}]` | unambiguous and streaming-friendly; hostile to humans and to most tooling | | Property on the container | the container states the kind of its member | works when the parent knows; the member is no longer self-describing | The sibling-key form carries one mechanical subtlety: a reader that streams has to find the discriminator before it can choose a target type, so it either requires the key to be written first or buffers the object until it appears. Some writers emit it first automatically; others need the property ordered deliberately. That is worth checking rather than assuming. ## Naming the subtypes Discriminator values are part of the contract, so they should read like contract vocabulary — short, stable, lowercase, chosen by whoever owns the API. Deriving them from code identifiers is the common shortcut and it fails the first time someone renames or moves a type, at which point a refactor has published a new wire value. ## Reading values you do not recognise Rejecting an unknown discriminator is the right default when you own every producer: an unknown value means a deployment skew or a bug, and failing loudly is what you want. A catch-all subtype that preserves the raw members is the right default when consumers must tolerate producers that ship new kinds first; it lets an older reader accept and even re-emit a payload it does not fully understand. Choosing tolerance also has an ordering consequence: readers must be able to tolerate the new kind *before* producers start sending it. ## Where this bites inside a framework - The instance is configured at startup, so a subtype registered later — during a lazy initialisation, say — may not be visible to the reader that is already serving requests. Register everything at configuration time. - Because framework-generated bodies use the same instance, a polymorphic model embedded in an error body follows the same rules; a model that works in a success path and fails in an error path usually means two instances, not two models. - A model that declares its own discriminator name locally while the rest of the service takes the global one produces a payload family with two conventions. Prefer the global setting and treat local overrides as exceptions that need a reason. The failure mode that makes all of this worth configuring explicitly is quiet: shape-based guessing works in testing, where the subtypes differ obviously, and becomes ambiguous in production the moment two subtypes share their required members.
- Why not let the reader pick the subtype whose properties match the document?Shape matching is ambiguous the moment two subtypes share their required members or have optional ones, and its result depends on the order candidates are tried. Adding a subtype can then change how existing documents resolve. It also forces the reader to buffer the whole node before choosing. An explicit discriminator is deterministic and stays deterministic.
- A new subtype is deployed to producers before consumers know about it. What happens?Readers configured to reject unknown discriminators fail those documents outright. That is why tolerance has an ordering consequence: if new kinds will appear before consumers update, roll out the reader's fallback subtype first, then start producing the new kind, rather than the other way around.
saying these in an interview costs you the question
- Expects the reader to infer the subtype from which properties happen to be present.
- Puts the code type identifier in the discriminator field.
- Registers subtypes lazily, after the instance is already serving requests.
- Assumes every element of a list inherits the first element's subtype.
- Treats an unrecognised discriminator as something to ignore quietly.