Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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/alias and the container rename_all are rejected (a struct variant’s own field renames stay in effect), and untagged unit variants aren’t supported — model the absent case with Option.

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.