Describing a Type
A type joins the wire model with three derives. This chapter covers what each one
emits, the Binding they produce, and the two attribute families every type
needs — naming and presence.
use capi_wire_model::{WireDecode, WireEncode, WireModel};
#[derive(WireModel, WireEncode, WireDecode)]
struct Shipment {
id: String,
weight_grams: u32,
}
The derives come from capi_wire_model, which a client crate depends on
directly and enables with its derive feature.
The three derives
They divide cleanly — three derives, one description, plus the shape and the redaction the description implies:
WireModelemits aconst BINDING: &'static Binding— a static description of the type’s wire shape, the single source of truth a codec reads; tooling can readT::BINDINGto learn a type’s shape without instantiating it. It also emits two impls the description implies:WireShape, the recursiveShapeevery field binding stores for its type, andRedact, the typed redaction transform that replaces the model’ssensitivefields with placeholders. TheRedactemission has a compile-time consequence: every non-skipfield’s type must implementRedact— derived models, the primitives,Option,Vec,BTreeMap<String, _>,Tristate,Bytes, and the datetime and decimal types all do, and a hand-written leaf adds two identity methods (Value Classes and Redaction).WireEncodeimplementsfn encode<E: Encoder>(&self, encoder: E)— the value pushes itself into whatever encoder a codec provides.WireDecode<'de>is the inverse, building aTfrom a decoder.
WireEncode/WireDecode reference <Self as WireModel>::BINDING, so a type that
derives either must also derive WireModel — the one exception being
#[wire(scalar)], which
carries no binding. You normally derive all three together, and derive only
WireEncode for a request type that never comes back or only WireDecode for a
response type you never send.
The derives accept named-field structs, single-field tuple structs (transparent newtypes), and enums in four tagging modes.
What the Binding holds
The binding is the whole point of the design, so it’s worth seeing:
pub struct Binding {
pub name: &'static str, // the type's wire name
pub envelope: Wrap, // struct-level wrap/unwrap policy
pub fields: &'static [FieldBinding], // in declaration order; empty for an enum
pub variants: &'static [VariantBinding], // non-empty marks this an enum
pub tagging: Tagging, // how variants are tagged
pub unknown_variant: UnknownPolicy, // what to do with an unrecognized tag
pub tag_field: Option<&'static FieldBinding>, // discriminator, for internal/adjacent
pub content_field: Option<&'static FieldBinding>,
pub ext: &'static [(&'static str, ExtVal)], // format-extension knobs
pub constants: &'static [ConstantBinding], // wire entries emitted from literals, no field behind them
}
Each entry in fields is a FieldBinding — the table a codec and the walker
actually read, one row per field:
pub struct FieldBinding {
pub rust_name: &'static str, // for error paths, and the default wire name source
pub wire: WireName, // the encode name plus any decode-only aliases
pub wrap: Wrap, // per-field wrap/unwrap policy
pub scalar: Option<ScalarRule>,// the leaf rendering rule, when the field is a scalar
pub presence: Presence, // whether the field must be present
pub shape: Shape, // the recursive wire shape of the field's type
pub class: ValueClass, // Plain, Volatile, or Sensitive(Mask)
pub flatten: bool, // spliced into the parent rather than nested
pub ext: &'static [(&'static str, ExtVal)],
}
A VariantBinding carries the same for each enum variant, constants included.
Everything is &'static — the binding is a table baked into the binary, not something
built at runtime. That is what makes it readable without a value, which is what makes
non-self-describing formats work. The ext bag
at the end is the format-extension mechanism.
For a non-generic type the derive emits a shared static; for a generic one it emits a
per-instantiation associated const, because a plain static cannot name type
parameters.
Two macros, one type
Request and response types often sit between two independent macros, and keeping them straight avoids most early confusion:
#[capi]is ergonomics only — it generates a constructor and setters so callers build a value fluently. It adds nothing to the wire mapping.#[derive(WireModel, WireEncode, WireDecode)]is the wire mapping. Every#[wire(...)]attribute in this part tunes that.
A type often carries both. They don’t interact — one shapes the Rust API, the other shapes the wire.
#[capi]
#[derive(Debug, Clone, WireModel, WireEncode)]
pub struct CreateShipment {
pub address: String, // required → a `new` parameter
pub weight_grams: Option<u32>, // optional → a `.weight_grams(...)` setter
pub tags: Vec<String>, // optional → a `.tags(...)` setter
}
// caller:
let s = CreateShipment::new("12 Main St")
.weight_grams(500)
.tags(["express", "insured"]);
#[capi] also emits a rustdoc # Parameters section documenting each field,
picking up the wire contract so readers of the model never have to guess. A field opts
out of that documentation with #[wire(doc = false)], which has no wire effect.
Argument types follow one rule. Primitives — the integer and float types, bool and
char — appear as themselves, so a bare literal infers: weight_grams(500). Every
other type is taken as impl Into<T>, which is what lets new("12 Main St") hand a
&str to a String field. A Vec<T> field accepts any iterator whose items convert
Into<T> (the IntoVec<T> blanket), which is why .tags(["express", "insured"])
reaches a Vec<String>; a Vec of a primitive takes an iterator of that primitive.
That’s the entire builder story; it’s deliberately dumb, so it never fights the wire mapping.
Naming
#[derive(WireModel, WireEncode, WireDecode)]
#[wire(rename_all = "camelCase")] // container: recase every field
pub struct Query {
#[wire(rename = "type")] // field: an exact wire name
pub kind: String,
pub r#type: String, // a raw identifier contributes its bare name, "type"
#[wire(alias = "max", alias = "cap")] // field: extra names accepted on decode only
pub limit: u32,
}
renamesets an exact wire name, on the container (the type’s own wire name) or a field. A reserved word needs no rename: a raw identifier (r#type) contributes its bare name,type, the way it resolves in Rust.rename_allrecases field names that lack an explicit rename. Acceptslowercase,UPPERCASE,camelCase,PascalCase,snake_case,SCREAMING_SNAKE_CASE,kebab-case,SCREAMING-KEBAB-CASE. On an enum it recases variant tags, not their fields.alias(repeatable) adds names accepted on decode; encode always writes the canonical name. This is the tool for an API that renamed a field but still sends the old one. Canonical names and aliases must be distinct across the struct.
Presence: required, optional, skipped
Decoding fails with a missing-field error naming the wire key unless a field is
optional. A field is optional when it is an Option, carries default, or is marked
optional/tristate:
pub struct Query {
pub kind: String, // required
pub limit: Option<u32>, // optional: absent → None
#[wire(default)] // optional: absent → Default::default()
pub page: u32,
#[wire(default = "default_page_size")] // optional: absent → the named function
pub size: u32,
#[wire(skip)] // never on the wire, absent from the binding
pub internal: Cache,
}
defaultmakes a field optional on the wire and fills an absent one fromDefault::default()or a named function. On anOptionfield,default = "path"fills the absent case from the path rather than collapsing toNone.skipremoves the field from the wire and from the binding entirely; it is produced from its default on decode. Use it for cached or derived state that has no business on the wire.
Omitting a field on encode is a separate question. The default needs no
attribute: a plain Option<T> field omits its key when it is None. The other
structure-shaping attributes are in Shaping Structures:
skip_encoding_if for a predicate, and optional/tristate for the richer
presence models.
Transparent newtypes
A single-field tuple struct is transparent: its wire form is the inner value’s, and its encode/decode delegate straight through.
#[derive(WireModel, WireEncode, WireDecode)]
pub struct Rate(u64); // on the wire this is just a number
Because it has no fields of its own, it accepts only rename, ext, bound, and
crate; its binding is fieldless and carries a ("transparent", true) ext marker so
tooling can recognize it. This is the cheapest way to give a primitive a domain type
without changing the wire at all.
Entries with no field
A wire object sometimes needs a member no Rust field should carry — JSON-RPC’s
"jsonrpc": "2.0", OData’s @odata.type beside a typed value. #[wire(constant(...))]
emits such an entry from a literal: on a container, #[wire(constant("jsonrpc" = "2.0"))]
names the entry outright and it leads the object; on a field,
#[wire(constant(suffix = "@odata.type", value = "Edm.Int64"))] derives the name from
the host field and rides its presence. Constants are encode-only and never members of
fields — on decode the key is an unknown one, handled as the type already handles
those — and they populate the binding’s constants slice for introspection.
Shaping Structures has the rules.
Container attributes the derive also takes
Three more container attributes rarely appear on a type and matter when they do:
bound = "…" (or bound(encode = "…", decode = "…")) adds where-clauses the derive
cannot infer for a generic type; crate = "path" renames the wire-model crate the
generated code refers to; and datetime_crate = "path" does the same for the
datetime carriers. Hand-Written Impls and the Value Tree
covers the generic and crate-path cases.
Next: Shaping Structures — what to do when the wire shape and the Rust shape don’t line up.