Enums on the Wire
Enums are where wire formats disagree most, so the model supports four tagging modes plus the two catch-all policies real APIs need. If you know Serde’s enum representations, these are the same four with the same names.
The four taggings
// external (the default) — no attribute needed
#[wire(tag = "type")] // internal: the tag rides inside the object
#[wire(tag = "type", content = "data")] // adjacent: { "type": ..., "data": ... }
#[wire(untagged)] // no tag; matched by shape
External (the default): a fieldless variant is a bare wire string (Active →
"active"); a newtype variant is a single-key object ({ "circle": …CircleData… }); a
struct variant is a single-key object holding its fields. Because the content is
nested, any inner type works. Struct variants here must be on a non-generic enum — on a
generic enum, wrap the struct in a newtype variant.
Internal (tag = "type"): each variant is an object carrying a discriminator field.
A unit variant is { "type": "active" }; a newtype variant’s inner struct fields sit
beside the tag ({ "type": "circle", …CircleData fields… }); an inline struct variant
is { "type": "point", "x": …, "y": … }. The inner type of an internally-tagged newtype
variant must be a struct with no unwrap envelope, and no content field may share the
tag’s wire name — that collision is rejected at derive time.
Adjacent (tag = "type", content = "data"): the discriminator and the content are
sibling fields. A unit variant is { "type": "active" } with no content key; a newtype
variant is { "type": "circle", "data": …CircleData… }. On decode the two keys may
appear in either order and other keys are ignored; a data variant missing its content
key is an error. Adjacent struct variants are rejected — use a newtype variant
wrapping a struct, or internal tagging.
Untagged: no discriminator at all. A newtype variant is just its inner value; a struct variant is a bare object of its fields. On decode the codec tries each variant in declaration order and the first that decodes the whole value wins — a variant matching only a prefix is rejected and the trial continues.
Untagged ordering is significant. Because unknown object keys are ignored, a struct variant whose required fields are a subset of a later variant’s will always win. Put the larger, more specific variant first. A variant’s own wire tag is unused here, so variant
rename/aliasand the containerrename_allare rejected (a struct variant’s own field renames stay in effect), and untagged unit variants aren’t supported — model the absent case withOption.
A mixed enum is externally tagged with some variants marked #[wire(untagged)]
individually: the untagged variants encode bare and are matched by shape, the tagged
variants must be unit and are matched by their wire string, and the whole enum decodes
through the untagged trial.
Open string enums
This is the common real case: a documented set of string values that the service will
grow later. #[wire(string_enum)] plus a #[wire(other)] catch-all handles unknown
values without failing:
#[derive(WireModel, WireEncode, WireDecode)]
#[wire(string_enum, rename_all = "snake_case")]
pub enum VoiceKind {
Cloned,
Preset,
#[wire(other)]
Other(String), // any value the API adds later lands here, losslessly
}
string_enum means external tagging, no generics, fieldless variants, plus an optional
Other(String). In exchange, WireModel also emits the string ergonomics sharing the
variants’ wire strings: Display and FromStr (erroring on an unknown string when the
enum is closed), plus infallible From<&str> / From<String> / From<&String> when
the Other catch-all makes construction total.
The catch-all works in both directions
It’s natural to read Other(String) as a decode-side safety net, but the encode side
matters just as much.
A service’s string vocabulary is usually a fixed set of sentinels, and turning them into real variants is the whole point: they’re discoverable, the compiler catches typos, and an editor can complete them. The hidden cost of a closed enum is that it also caps what you can send. When the service adds a value, you can’t send it — even knowing the exact string — until the client library ships an update and you upgrade.
Because Other(String) re-encodes the string it holds verbatim, that ceiling
disappears. You can construct one for a value the client has never heard of and send it
immediately:
let kind = VoiceKind::Other("experimental".into()); // goes out as "experimental"
This is also why the infallible From<&str>/From<String> conversions appear only when
the catch-all is present: with it, every string is a valid value, so construction cannot
fail.
Note that the encode half specifically needs the String-carrying form. A fieldless
#[wire(other)] variant absorbs an unknown tag on decode but keeps nothing, so it can
neither round-trip the original value nor express a new one on the way out.
The pattern gives you strongly typed values for the vocabulary you know about without making that vocabulary a limit: a client that survives the service adding a value on a Tuesday, and can use it the same afternoon.
Catch-alls: other vs capture
Two different policies for “something I don’t recognize.”
#[wire(other)] catches an unrecognized tag. A fieldless #[wire(other)] variant
absorbs any unknown tag; without one, an unknown tag is a decode error.
Other(String), which captures the unrecognized wire string itself, is supported only
on an external-tagged enum whose other variants are all fieldless. It’s rejected on an
untagged enum, takes no alias, and only one variant may carry it.
#[wire(capture)] catches an unrecognized value. It marks a newtype variant — e.g.
Other(Value) — as the last-resort catch-all: any value no other variant matches is
decoded whole and self-describingly into the variant’s inner value, then re-encoded
transparently with no tag wrapper. The inner type must be able to absorb an arbitrary
value, meaning a self-describing type such as
capi_wire_value::Value — not a scalar, Option,
or sequence.
Two things to know about capture. Its round trip is semantic, not byte-exact: the value re-emits as the codec’s canonical form, so original bytes survive only for already-canonical input, and numbers re-emit through the value model’s numeric laning. And it is reached only as a last resort — never by a wire tag, and on an untagged enum it’s held out of the trial loop entirely so concrete variants win regardless of order.
An enum may use other or capture, not both. Either policy also answers a
missing tag: an internally or adjacently tagged object whose tag field is absent
or null is an object the enum’s own vocabulary cannot name, so it resolves the way
an unrecognized tag does — into the catch-all — and only an enum with no catch-all
rejects it as an invalid envelope.
Flattening an externally tagged enum
An externally tagged enum field can be #[wire(flatten)]ed into its parent, so the
variant’s key sits beside the parent’s own keys: { "placeId": "…", "via": true }
rather than { "target": { "placeId": "…" }, "via": true }. That is the shape a
protobuf oneof takes when a service transcodes it to JSON. Exactly one variant
key may be present on decode, none decodes an Option<Enum> as None, and an
untagged enum — which has no key to splice — is refused.
Shaping Structures has the rules beside the other
flatten forms.
Variant attributes
Variants take rename, alias (repeatable), other, capture, ext, a
per-variant untagged, and constant("name" = value) where the variant renders a
map of its own. The enum’s rename_all recases variant tags, not their fields.
Variant tags and aliases must be distinct across the enum. Struct-variant fields accept
the ordinary field attributes except skip_encoding_if, optional, and tristate.
A protobuf enum numbers its variants rather than naming them; that vocabulary
(variant_number, open versus closed enums) belongs to the protobuf contract and
is in the next chapter.
Next: Format Extensions — how a format adds vocabulary the core never knew about.