Scalars, Numbers, and Money
Leaf values are where wire bugs hide: a number quoted as a string, bytes that should have been base64, money rounded through a float. The wire model handles each with a scalar rule — one per field, declared on the field, checked against its type at derive time.
Numbers
Some APIs send numbers as JSON strings ("1736380800"). Model the field as its real
numeric type and annotate the wire form; never change the Rust type to String to
match the wire.
#[wire(number(as = "string"))] pub amount_cents: i64, // reads/writes "12345"
number(...) takes three knobs:
| Knob | Effect |
|---|---|
as = "string" / as = "number" | a quoted string on the wire, or the native number lane (the default) |
scale = N | a fixed decimal scale |
rounding = "..." | how a precision-dropping encode rounds: half_even (the default), half_up, half_down, up, down, ceil, floor, or unnecessary, which refuses a value that would need rounding |
Bytes
A Bytes field renders as an encoded string with one of three rules:
#[wire(base64)] pub data: Bytes,
#[wire(base64url)] pub token: Bytes,
#[wire(hex)] pub digest: Bytes,
The distinction between Bytes and Vec<u8> matters and is easy to trip over: in the
wire model’s core shape, Vec<u8> is a repeated integer — a JSON array of numbers,
and in protobuf a repeated field. Bytes is a genuine byte string. Reach for Bytes
whenever the value is opaque binary data — and give it a rule: with none set, a text
codec renders a Bytes field as an array of byte values, which is rarely what the
service wants. Sentinel mapping applies to leaves of every kind: none_when turns a
service’s -1 or "" into None on a numeric or string field as readily as on a
date — the datetime chapter shows it.
Joined lists
A Vec of scalar leaves can render as one separator-joined string:
#[wire(joined = ",")] pub kinds: Vec<Kind>, // kinds=a,b,c
The separator is a single character, it must not appear inside an element, and a Vec
containing a single empty string is rejected on encode. This is common in header and
query values — for example a typed field mask:
#[endpoint(location(header))]
#[wire(rename = "x-goog-fieldmask", joined = ",")]
pub field_mask: Vec<FieldMask<SearchNearbyResponse>>,
FieldMask<T> (from google_places) is a phantom-typed newtype —
FieldMask<T>(Cow<'static, str>, PhantomData<fn() -> T>) — so a field mask is tied at
compile time to the response type it selects into, catching a mask that names a field
the response doesn’t have. The #[endpoint(location(header))] half is endpoint routing,
covered in the endpoint contract; the joined half is the
wire rule.
Money and decimals
Never model money as f64. Two supported approaches:
Integer minor units. An i64 count of cents plus number(as = "string") when the
API quotes it. This is what grok does, and it needs no extra types.
The decimal carriers. capi_wire_decimal provides two types that derive the wire
traits and just work as field types — no #[wire] key needed:
| Type | Shape |
|---|---|
Decimal | signed 128-bit coefficient with a per-value power-of-ten exponent; any scale, exact to ~38 significant digits |
FixedDecimal<const SCALE: u8> | the same with the scale pinned in the type — FixedDecimal<2> for a two-place money field, the way a SQL NUMERIC(_, s) column fixes it |
Both parse, format, and rescale but do no arithmetic. That is deliberate: Rust has
no core decimal type, and hard-coding a third-party one into an API client would tie
every downstream user to that choice. Instead they are carriers that know how to
cross the wire, and a third-party decimal crate plugs in by implementing
ToWireDecimal/FromWireDecimal for its own type. The framework never depends on a
decimal-math library, and a value still round-trips exactly.
Both encode on the raw-number lane, so through a JSON codec a decimal is a bare number
preserved to the digit; add #[wire(number(as = "string"))] to send it quoted instead.
Value classes
Two more attributes say what kind of value a field holds rather than how it
is spelled: #[wire(sensitive)] for a secret and #[wire(volatile)] for a
value that varies run to run. They have no effect on the bytes a codec writes
— they are properties recorded in the binding — but they drive the recorder’s
masking, the placeholder a fixture stores, and the typed redaction transform a
request goes through to be born without its secrets.
Value Classes and Redaction is the whole story.
Where the scalar rules stop
One scalar rule per field, and the datetime families own their field’s leaf conversion
outright — so #[wire(timestamp(...))] and friends do not combine with number,
base64, or joined. They are the next chapter, and they are the
largest attribute family in the model for a good reason: a silent wrong unit or offset
is the costliest wire bug there is.
For a leaf whose format no rule expresses, write a
WireScalar type or route
the field through a via proxy.