strede
Async, zero-alloc, pull-based deserialization for Rust.
The name
Strede is a STREaming DEserializer - a name deliberately close to serde, the library it complements. It's also an Old English word meaning "stream" or "channel."
Streaming is the important use case externally - but internally, the architecture is built around coroutines. COroutine DEserializer just doesn't abbreviate as well.
Quickstart
For most users, strede looks like serde: derive Deserialize on your types and
pass them to a format backend.
use Deserialize;
Then deserialize from JSON:
use JsonDeserializer;
let json = r#"{"host": "localhost", "port": 8080}"#;
let config = deserialize.await??;
For streaming/owned use (reading from an async byte source), derive
DeserializeOwned and use strede_json::chunked instead. The rest of this
document covers the lower-level traits for when you need to implement
Deserialize manually or write a custom format backend.
The problem with serde
serde's Deserializer is push-based: it calls methods on your Visitor,
driving the process itself. This works well for synchronous formats but
doesn't compose naturally with async streams - often requiring the whole data stream in memory, or requiring alloc.
The strede approach
strede flips the model. You advance the stream through a closure and probe
entry types with futures that resolve Ok(Probe::Hit(...)) or Ok(Probe::Miss).
A Claim token is returned back to the deserializer as proof-of-consumption:
// Advance the stream, racing two type probes
let value = d.entry.await?;
Probe results:
Ok(Probe::Hit((Claim, T)))- token matched; threadClaimback toentry.Ok(Probe::Miss)- type mismatch; stream was not consumed.Err(e)- fatal format error (malformed data, I/O failure).Pending- no data available yet (I/O backpressure only, never a type mismatch).
select_probe! polls all arms; Miss marks an arm done, first Hit wins,
Err short-circuits. This replaces the visitor pattern with no heap allocation.
Trait overview
Borrow family ('de - zero-copy)
Deserialize<'de> T::deserialize(d, ()) → Result<Probe<(Claim, T)>, Error>
Deserializer<'de> d.entry(|[e1, ..eN]| async { Ok(Probe::Hit((claim, r))) })
→ Result<Probe<(Claim, R)>, Error>
Entry<'de> e.deserialize_bool() → Result<Probe<(Claim, bool)>, Error>
e.deserialize_u8/u16/u32/u64/u128()
e.deserialize_i8/i16/i32/i64/i128()
e.deserialize_f32/f64()
e.deserialize_char()
e.deserialize_str() → Result<Probe<(Claim, &'de str)>, Error>
e.deserialize_str_chunks() → Result<Probe<StrAccess>, Error>
e.deserialize_bytes() → Result<Probe<(Claim, &'de [u8])>, Error>
e.deserialize_bytes_chunks() → Result<Probe<BytesAccess>, Error>
e.deserialize_map() → Result<Probe<MapAccess>, Error>
e.deserialize_seq() → Result<Probe<SeqAccess>, Error>
e.deserialize_option::<T, ()>(()) → Result<Probe<(Claim, Option<T>)>, Error>
e.skip() → Result<Claim, Error>
e.deserialize_value::<T, E>(e) → Result<Probe<(Claim, T)>, Error>
Chunk<Data, Done> Data(item) | Done(claim)
StrAccess chunks.next_str(|&str| -> R) → Result<Chunk<(Self, R), Claim>, Error>
chunks.fork() → Self
BytesAccess chunks.next_bytes(|&[u8]| -> R) → Result<Chunk<(Self, R), Claim>, Error>
chunks.fork() → Self
MapAccess map.iterate(arms: impl MapArmStack) → Result<Probe<(Claim, Outputs)>, Error>
map.fork() → Self
MapKeyProbe kp.deserialize_key::<K, Extra>(extra) → Result<Probe<(Claim, K)>, Error>
kp.fork() → Self
MapKeyClaim kc.into_value_probe() → MapValueProbe
MapValueProbe vp.deserialize_value::<V, Extra>(extra) → Result<Probe<(Claim, V)>, Error>
vp.skip() → Result<Claim, Error>
vp.fork() → Self
MapValueClaim vc.next_key() → Result<Probe<Chunk<(MapKeyProbe, R), Claim>>, Error>
SeqAccess seq.next(|[e]| async { Ok(Probe::Hit((claim, r))) })
→ Result<Probe<Chunk<(Self, R), Claim>>, Error>
seq.fork() → Self
SeqEntry e.get::<T, ()>(()) → Result<Probe<(Claim, T)>, Error>
e.skip() → Result<Claim, Error>
Owned family ('s - streaming/chunked, no zero-copy borrows)
Mirrors the borrow family but drops deserialize_str / deserialize_bytes;
strings/bytes must go through chunks. StrAccessOwned::next_str /
BytesAccessOwned::next_bytes take self + a sync closure FnOnce(&str) -> R
to map the short-lived borrow to an owned value.
DeserializeOwned T::deserialize_owned(d, ()) → Result<Probe<(Claim, T)>, Error>
DeserializerOwned d.entry(self, closure) → Result<Probe<(Claim, R)>, Error>
EntryOwned (same probes minus deserialize_str/bytes, plus skip)
StrAccessOwned chunks.next_str(self, |&str| -> R) → Result<Chunk<(Self, R), Claim>, Error>
chunks.fork() → Self
BytesAccessOwned chunks.next_bytes(self, |&[u8]| -> R) → Result<Chunk<(Self, R), Claim>, Error>
chunks.fork() → Self
SeqAccessOwned seq.next(self, closure) → Result<Probe<Chunk<(Self, R), Claim>>, Error>
seq.fork() → Self
SeqEntryOwned e.get(self, ()) → Result<Probe<(Claim, T)>, Error>
e.skip(self) → Result<Claim, Error>
The two families are independent - no supertrait relationship, no blanket impls.
The Claim for maps, sequences, and streaming strings/bytes is returned via
the Done(claim) variant of Chunk - not from the initial probe.
In the borrow family, stream advancement (entry) takes self - explicitly
sequential. Type probes consume self - pass N > 1 handles to entry to race
them with select_probe! without borrow conflicts.
Owned family - parallel scanning
The owned family reads from a streaming source where data arrives
incrementally. When entry passes multiple handles, or when you fork an
accessor, the resulting readers share the same underlying buffer.
You must drive all forked readers concurrently - typically via
select_probe!. Sequentially awaiting one reader to completion before
polling another will deadlock: the first reader may block waiting for buffer
data that cannot arrive until all sibling readers have consumed the current
chunk. This is safe to do: forked readers never interfere with each other,
and every reader is automatically suspended and resumed as new data becomes
available, provided all readers are being polled.
Deriving
The derive macro generates a Deserialize impl that calls d.entry(), enters
the map, and dispatches on each string key to fill the struct fields.
Tuple structs (e.g. struct Pair(u32, u32)) deserialize from JSON arrays.
For enums, the default representation is externally tagged: unit variants
are encoded as bare strings, and non-unit variants as single-key maps where the
key is the variant name. Newtype variants map the key to the inner value
directly; tuple variants map it to a JSON array; struct variants map it to a
JSON object. Unknown variant names return Probe::Miss.
#[derive(DeserializeOwned)] generates the equivalent for the owned family,
using deserialize_str_chunks for streaming key matching.
Attributes
#[strede(rename = "wire_name")] on a field or variant changes the wire name
used for matching without affecting the Rust identifier:
#[strede(rename_all = "convention")] on a struct or enum converts all
field/variant names to the given case. An explicit rename on a field or
variant takes priority. Supported conventions: "lowercase", "UPPERCASE",
"PascalCase", "camelCase", "snake_case", "SCREAMING_SNAKE_CASE",
"kebab-case", "SCREAMING-KEBAB-CASE":
#[strede(alias = "alt_name")] on a field or variant adds an additional wire
name that matches during deserialization. Can be specified multiple times and
works alongside rename. Cannot be used on untagged variants:
// Accepts "host", "hostname", or "server" as the key.
#[strede(tag = "field")] on an enum marks it as internally tagged: the
variant discriminant is stored as a named field inside the map rather than
as the outer key. For example, {"type": "Move", "x": 1.0, "y": 2.0} with
#[strede(tag = "type")] dispatches on "type" and deserializes the
remaining fields as the variant's payload. rename, rename_all, and
alias apply to variant names as normal.
Both families support all variant kinds. For newtype/tuple variants, the inner
type must itself deserialize from a map - the tag facade only surfaces
deserialize_map, so primitives inside a newtype are not supported.
#[strede(tag = "t", content = "c")] on an enum marks it as adjacently
tagged: the outer map has a tag field and a separate content field; the
variant payload lives entirely inside the content value. Key order is
irrelevant. Unit variants have no content field.
Both families are supported.
#[strede(flatten)] on a named struct field merges that field's map keys into
the parent struct's outer map iteration - no wrapping map token. Unknown keys
not claimed by the outer struct or any flattened type are silently skipped.
Multiple flatten fields per struct are supported. Both families are supported:
the flattened type must implement Deserialize<'de> (borrow) or
DeserializeOwned (owned).
// Borrow family
// Deserializes: {"name": "p", "x": 1.0, "y": 2.0}
For structs with 3 or more flatten fields, use #[strede(flatten(boxed))]
instead. Deeply-nested StackConcat types produce large async state machines
that can overflow the stack; flatten(boxed) opts each continuation future
into Box::pin to break the chain. Any flatten field annotated flatten(boxed)
enables boxed mode for the entire flatten chain. Requires the alloc feature.
// Deserializes: {"x": 0.0, "y": 0.0, "r": 0, "g": 0, "b": 0, "w": 1.0, "h": 1.0}
#[strede(untagged)] on an enum or individual variant enables shape-based
matching instead of name tags. Variants are tried in declaration order; first
Hit wins. Can be mixed with tagged variants - tagged paths are tried first,
untagged variants act as fallback:
#[strede(other)] on a unit enum variant acts as a catch-all: any
unrecognized discriminant returns this variant instead of Probe::Miss. For
map-keyed variants the unknown key's value is skipped first. Only one other
variant is allowed per enum; it cannot be combined with rename, alias, or
untagged, and cannot coexist with untagged variants:
// "Ok" → Status::Ok, "Error" → Status::Error, "anything_else" → Status::Unknown
#[strede(default)] on a struct field uses Default::default() when the
field is missing. #[strede(default = "expr")] evaluates the expression
instead - if expr is a function path it is called, otherwise the value
is used directly:
#[strede(skip_deserializing)] on a struct field excludes it from
deserialization entirely - the field always uses its default. Requires
default or default = "fn" to also be set.
#[strede(allow_unknown_fields)] on a struct skips unknown map keys
(consuming and discarding their values) instead of returning Probe::Miss:
// Deserializes successfully even if the JSON has extra fields.
#[strede(transparent)] on a struct with exactly one non-skipped field
makes it deserialize as that field directly, with no map or array wrapper:
;
// Deserializes from a bare number like 3.14, not [3.14] or {"0": 3.14}.
#[strede(deserialize_with = "path")] on a struct field uses a custom
function instead of T::deserialize (borrow family).
#[strede(deserialize_owned_with = "path")] is the owned-family equivalent.
#[strede(with = "module")] is shorthand for both, using
module::deserialize and module::deserialize_owned.
#[strede(from = "FromType")] deserializes FromType and converts to the
target via From::from. Works at container level (the whole struct/enum
is produced from FromType) and field level (just that field is converted).
#[strede(try_from = "FromType")] is the same but uses TryFrom::try_from;
a failed conversion returns Probe::Miss rather than an error, because
conversion failures are type mismatches, not format violations. Both are
mutually exclusive with deserialize_with / deserialize_owned_with / with.
#[strede(crate = "path")] on a struct or enum overrides the default crate
path (::strede) used in generated code. Useful when strede is re-exported
under a different name:
#[strede(bound = "T: MyTrait")] overrides the where-clause predicates that
the derive macro would normally generate automatically.
At container level it replaces all auto-generated predicates for the
entire impl block (applies to both borrow and owned derives).
At field level it replaces the predicate for that one field; other fields
keep their auto-generated bounds. An empty string (bound = "") suppresses
bounds entirely.
// Replace the auto T: Deserialize<'de> bound with a custom supertrait.
// Suppress bounds on one field while keeping them on others.
#[strede(borrow)] on a struct field controls how 'de: 'lifetime bounds
are generated for the borrow-family derive:
- No attribute (default): emits
'de: 'afor lifetimes in top-level&'a T,&'a mut T, andCow<'a, T>. #[strede(borrow)]: emits'de: 'afor every lifetime in the type.#[strede(borrow = "'a + 'b")]: emits bounds only for the listed lifetimes. Accepts+or,as separators.
Generic type parameters always get T: Deserialize<'de> regardless:
use Cow;
Utility types
Skip - Deserialize<'de> + DeserializeOwned (Extra = ()). Discards any
token unconditionally. Always Hit.
Match - Checks a token for an exact content match. Extra is the expected
value:
| impl | family | token |
|---|---|---|
Deserialize<'de, &'a str> |
borrow | string |
Deserialize<'de, &'a [u8]> |
borrow | bytes |
DeserializeOwned<&'a str> |
owned | string |
DeserializeOwned<&'a [u8]> |
owned | bytes |
Returns Hit(Match) when content equals extra, Miss otherwise (stream not
advanced). Use with deserialize_value inside select_probe! for string-tag
dispatch:
d.entry
The borrow-family str/bytes impls race the zero-copy probe against the chunked
fallback (N=2 entry handles) so escaped strings are handled without a separate
d.entry call.
MatchVals<T> - generalises Match to return a caller-supplied T on a
content match. The same four family × token impls as Match. Extra is an
array of (&str, T) / (&[u8], T) pairs, or plain [&str; N] / [&[u8]; N]
for MatchVals<usize> index-only dispatch. T must be Copy:
// Return the matched enum variant
e..await
// Return the matched index
e..await
Match is a thin wrapper that delegates to MatchVals.
UnwrapOrElse<T> - wraps T: Deserialize<'de, Extra> with an async
fallback. Extra is (F, InnerExtra) where F: AsyncFnOnce() -> T. Arm 1
tries T::deserialize; if it misses, arm 2 calls skip() to consume the entry
and then calls the fallback. The stream is always advanced exactly once:
e..await
The derive macro uses UnwrapOrElse<MatchVals<usize>> with a sentinel fallback
so unknown map keys produce a sentinel index while still consuming the key entry.
Status
Early development - core traits stable with both borrow and owned families.
JSON backend implemented: in-memory borrow-family deserializer (JsonDeserializer)
and chunked/streaming owned-family deserializer (strede-json::chunked).
Workspace
| crate | description |
|---|---|
strede |
core traits (borrow + owned families), shared_buf module |
strede-json |
JSON deserializer backend (in-memory + chunked/streaming) |
strede-derive |
proc-macro: Deserialize, DeserializeOwned, select_probe! |