Async, zero-alloc, pull-based deserialization for Rust.
Strede is a STREaming DEserializer - a name deliberately close to serde, the library it complements. It's also an Old English / Norwegian word meaning "stream" or "channel". It's pronounced STREH-duh.
Streaming is the important use case externally - but internally, the architecture is built around coroutines. COroutine DEserializer just doesn't abbreviate as well.
For most users, strede looks like serde: derive Deserialize on your types and
pass them to a format backend.
use strede::Deserialize;
#[derive(Deserialize)]
struct Config {
host: String,
port: u16,
}
#[derive(Deserialize)]
#[strede(tag = "type")]
enum Event {
Ping,
Move { x: f64, y: f64 },
}Then deserialize from JSON:
use strede::Deserialize;
use strede_json::JsonDeserializer;
let json = br#"{"host": "localhost", "port": 8080}"#;
let (_, config) = Config::deserialize(JsonDeserializer::new(json), ()).await?.unwrap();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.
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.
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(|[e1, e2]| async {
select_probe! {
async move {
let (claim, f) = hit!(e1.deserialize_value::<f32, ()>(()).await);
Ok(Probe::Hit((claim, Value::Float(f))))
},
async move {
let (claim, s) = hit!(e2.deserialize_str().await);
Ok(Probe::Hit((claim, Value::Str(s))))
},
@miss => Ok(Probe::Miss),
}
}).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.
select_probe! also supports two advanced forms:
select_probe!(biased; ...)— when multiple arms are ready simultaneously, the earliest arm in declaration order wins instead of an unspecified one.kill!(i)— a macro available inside any arm that schedules armifor cancellation on the next poll. Useful when one arm has gathered enough information to know a sibling can never win (e.g. the zero-copy string borrow succeeded, so the chunked fallback arm can be dropped before the next.await).
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_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_number_chunks() → Result<Probe<NumberAccess>, Error>
e.deserialize_map() → Result<Probe<MapAccess>, Error>
e.deserialize_seq() → Result<Probe<SeqAccess>, Error>
e.deserialize_map_into::<T, ()>(()) → Result<Probe<(Claim, T)>, Error>
e.deserialize_seq_into::<T, ()>(()) → Result<Probe<(Claim, T)>, Error>
e.deserialize_option::<T, ()>(()) → Result<Probe<(Claim, Option<T>)>, Error>
e.deserialize_value::<T, E>(e) → Result<Probe<(Claim, T)>, Error>
e.skip() → Result<Claim, Error>
Chunk<Data, Done> Data(item) | Done(claim)
NextKey<KP, MC> Entry(KP) | Done(MC)
StrAccess chunks.next_str(|&str| -> R) → Result<Chunk<(Self, R), Claim>, Error>
BytesAccess chunks.next_bytes(|&[u8]| -> R) → Result<Chunk<(Self, R), Claim>, Error>
NumberAccess chunks.next_number_chunk(|&str| -> R) → Result<Chunk<(Self, R), Claim>, Error>
MapAccess map.iterate(arms: impl MapArmStack) → Result<Probe<(Claim, Outputs)>, Error>
MapKeyProbe kp.deserialize_key::<K>(extra) → Result<Probe<(KeyClaim, K)>, Error>
kp.fork() → Self
MapKeyClaim kc.into_value_probe() → MapValueProbe
MapValueProbe vp.deserialize_value::<V>(extra) → Result<Probe<(ValueClaim, V)>, Error>
vp.deserialize_map_into::<V>(extra) → Result<Probe<(ValueClaim, V)>, Error>
vp.deserialize_seq_into::<V>(extra) → Result<Probe<(ValueClaim, V)>, Error>
vp.skip() → Result<ValueClaim, Error>
vp.fork() → Self
MapValueClaim vc.next_key(..) → Result<NextKey<MapKeyProbe, MapClaim>, Error>
SeqAccess seq.next(|[e]| async { Ok(Probe::Hit((claim, r))) })
→ Result<Probe<Chunk<(Self, R), Claim>>, Error>
SeqEntry e.get::<T, ()>(()) → Result<Probe<(Claim, T)>, Error>
e.get_map_into::<T, ()>(()) → Result<Probe<(Claim, T)>, Error>
e.get_seq_into::<T, ()>(()) → Result<Probe<(Claim, T)>, Error>
e.fork() → Self
e.skip() → Result<Claim, Error>
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; includes
deserialize_number_chunks, deserialize_map_into, deserialize_seq_into)
StrAccessOwned chunks.next_str(self, |&str| -> R) → Result<Chunk<(Self, R), Claim>, Error>
BytesAccessOwned chunks.next_bytes(self, |&[u8]| -> R) → Result<Chunk<(Self, R), Claim>, Error>
NumberAccessOwned chunks.next_number_chunk(self, |&str| -> R) → Result<Chunk<(Self, R), Claim>, Error>
SeqAccessOwned seq.next(self, closure) → Result<Probe<Chunk<(Self, R), Claim>>, Error>
SeqEntryOwned e.get(self, ()) → Result<Probe<(Claim, T)>, Error>
e.get_map_into(self, ()) → Result<Probe<(Claim, T)>, Error>
e.get_seq_into(self, ()) → Result<Probe<(Claim, T)>, Error>
e.fork() → Self
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.
The owned family reads from a streaming source where data arrives
incrementally. When entry passes multiple handles, or when you fork a
probe item (Entry, SeqEntry, MapKeyProbe, MapValueProbe,
EnumVariantProbe), 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.
Structural accessors (MapAccess, SeqAccess, EnumAccess, StrAccess,
BytesAccess, NumberAccess) do not have fork — once opened they may be
wrapped by layout adapters (e.g. TagAwareMap) that are single-use state
machines. Concurrency inside a structural access is handled by the internal
map_arm / enum_arm infrastructure.
#[derive(strede::Deserialize)]
struct Config {
host: String,
port: u16,
timeout_ms: u64,
}
#[derive(strede::Deserialize)]
enum Message {
Ping, // unit - "Ping"
SetRate(f64), // newtype - {"SetRate": 1.5}
Move(i32, i32), // tuple - {"Move": [10, 20]}
Resize { width: u32, height: u32 }, // struct - {"Resize": {"width": 80, "height": 24}}
}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.
#[strede(rename = "wire_name")] on a field or variant changes the wire name
used for matching without affecting the Rust identifier:
#[derive(strede::Deserialize)]
struct Config {
#[strede(rename = "type")]
kind: String,
}#[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":
#[derive(strede::Deserialize)]
#[strede(rename_all = "camelCase")]
struct User {
first_name: String, // matches "firstName"
last_name: String, // matches "lastName"
#[strede(rename = "id")]
user_id: u64, // explicit rename wins - matches "id"
}
#[derive(strede::Deserialize)]
#[strede(rename_all = "snake_case")]
enum Event {
UserCreated, // matches "user_created"
OrderPlaced(u64), // matches {"order_placed": ...}
}#[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:
#[derive(strede::Deserialize)]
struct Config {
#[strede(alias = "hostname", alias = "server")]
host: String,
}
// 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.
#[derive(strede::DeserializeOwned)]
#[strede(tag = "type")]
enum Event {
Ping, // {"type": "Ping"}
Move { x: f64, y: f64 }, // {"type": "Move", "x": 1.0, "y": 2.0}
Teleport(Point), // {"type": "Teleport", "x": 3.0, "y": 4.0}
}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.
#[derive(strede::Deserialize, strede::DeserializeOwned)]
#[strede(tag = "t", content = "c")]
enum Event {
Ping, // {"t": "Ping"}
Move { x: f64, y: f64 }, // {"t": "Move", "c": {"x": 1.0, "y": 2.0}}
Wrap(Point), // {"t": "Wrap", "c": {"x": 3.0, "y": 4.0}}
}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
#[derive(strede::Deserialize)]
struct Inner { x: f64, y: f64 }
#[derive(strede::Deserialize)]
struct Outer {
name: String,
#[strede(flatten)]
pos: Inner,
}
// Deserializes: {"name": "p", "x": 1.0, "y": 2.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:
#[derive(strede::Deserialize)]
enum Msg {
Ping, // tagged: "Ping"
Data { x: i32 }, // tagged: {"Data": {"x": 1}}
#[strede(untagged)] Raw(bool), // untagged: tries bool directly
}#[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:
#[derive(strede::Deserialize)]
enum Status {
Ok,
Error,
#[strede(other)]
Unknown,
}
// "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:
#[derive(strede::Deserialize)]
struct Config {
host: String,
#[strede(default)]
port: u16, // 0 if missing
#[strede(default = "default_timeout")]
timeout_ms: u64, // default_timeout() if missing
#[strede(default = "3")]
retries: u32, // literal 3 if missing
}#[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:
#[derive(strede::Deserialize)]
#[strede(allow_unknown_fields)]
struct Config {
host: String,
port: u16,
}
// 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:
#[derive(strede::Deserialize)]
#[strede(transparent)]
struct Meters(f64);
// 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:
#[derive(other_crate::Deserialize)]
#[strede(crate = "other_crate::strede")]
struct Foo { x: u32 }#[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.
trait MyDeserialize<'de>: strede::Deserialize<'de> {}
#[derive(strede::Deserialize)]
#[strede(bound = "T: MyDeserialize<'de>")]
struct Wrapper<T> {
inner: T,
}
// Suppress bounds on one field while keeping them on others.
#[derive(strede::Deserialize)]
struct Pair<T, U> {
#[strede(bound = "T: Copy + strede::Deserialize<'de>")]
first: T,
second: U, // auto-bound: U: Deserialize<'de>
}#[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 std::borrow::Cow;
#[derive(strede::Deserialize)]
struct Borrowed<'a> {
name: &'a str, // auto: 'de: 'a
data: Cow<'a, [u8]>, // auto: 'de: 'a
}
#[derive(strede::Deserialize)]
struct Explicit<'a, 'b, 'c> {
#[strede(borrow = "'a + 'b")]
inner: Custom<'a, 'b, 'c>, // only 'de: 'a and 'de: 'b
}Skip - Deserialize<'de> + DeserializeOwned (Extra = ()). Discards any
token unconditionally. Always Hit.
Match - Checks a string token for an exact content match. Extra is the
expected string:
| impl | family | token |
|---|---|---|
Deserialize<'de, &'static str> |
borrow | string |
DeserializeOwned<&'static str> |
owned | string |
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(|[e1, e2]| select_probe! {
e1.deserialize_value::<Match, &str>("ok"),
e2.deserialize_value::<Match, &str>("err"),
@miss => Ok(Probe::Miss),
})The borrow-family impl races the zero-copy deserialize_str probe against the
chunked fallback (N=2 entry handles) so escaped strings are handled without a
separate d.entry call. Match and MatchVals have independent implementations.
MatchVals<T, const N> - generalises Match to return a caller-supplied T on a
content match. The same two family impls as Match. Extra is an array of
N (&'static str, T) pairs. T must be Copy:
// Return the matched enum variant
e.deserialize_value::<MatchVals<MyEnum>, _>([("a", MyEnum::A), ("b", MyEnum::B)]).await
// Return the matched index
e.deserialize_value::<MatchVals<usize>, _>([("foo", 0usize), ("bar", 1usize)]).awaitUnwrapOrElse<T, F> - 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.deserialize_value::<UnwrapOrElse<MyType>, _>((async || MyType::default(), ())).awaitThe derive macro uses UnwrapOrElse<MatchVals<usize>> with a sentinel fallback
so unknown map keys produce a sentinel index while still consuming the key entry.
This section is for authors writing a new format crate (CBOR, MessagePack, etc.), not for users of an existing backend.
You only need to implement the family (or families) your format can support.
A simple in-memory format usually implements the borrow family
(Deserializer, Entry, MapAccess, SeqAccess, StrAccess, BytesAccess,
NumberAccess). A chunked/streaming format implements the owned family
(DeserializerOwned, EntryOwned, and the *Owned accessor traits). The two
families are independent; you can implement one, both, or neither for a given
entry type.
The single most important invariant: type mismatches must return
Ok(Probe::Miss), never Err. Err is reserved for fatal format errors
(malformed data, I/O failure). Pending means only "waiting for I/O" — never
a type mismatch. All probe methods consume self.
In serde, your deserializer drives the process by calling methods on a
Visitor (visit_u64, visit_str, …). In strede the roles are reversed: the
caller probes the entry with deserialize_number_chunks, deserialize_str,
etc. and gets Hit/Miss back. Your implementation does not call any visitor
methods — it inspects the current token and returns the appropriate Probe.
Unlike serde — where u32: Deserialize is provided by the serde crate itself
— in strede Deserialize<'de, D> for numeric types (u8, u32, f64, …) and
bool must be provided by each format backend. For most formats the right
approach is to decode the wire value directly — inspect the current token,
cast or convert in place, and return Hit or Miss. deserialize_number_chunks
exists as a common denominator for text-based formats and arbitrary-precision
integers; for ordinary numeric types, routing through it just to parse a string
back into a number adds unnecessary overhead. That said, providing it as a
fallback (e.g. for callers that want arbitrary-precision access) is fine.
String-like types (String, Cow<str>, Box<str>, &'de str) are already
provided by core strede generically via deserialize_str / deserialize_str_chunks
— your backend gets them for free once Entry is implemented. The dividing line
is: types whose wire encoding is format-independent ship in core; types whose
encoding is format-dependent (numbers, booleans, raw bytes, and Vec<u8>'s
bytes-vs-seq disambiguation — see below) are your responsibility.
Unlike String/Cow<str>/etc., Vec<T>: Deserialize / DeserializeOwned is
not provided by core strede — each backend implements it. This is
deliberate: a Vec<u8> can in principle be read either as raw bytes or as a
sequence of u8 elements, and whether it's safe to try both and take
whichever hits depends entirely on your wire format. Self-describing formats
with a dedicated byte-string token (JSON, CBOR, MessagePack) can always tell
the two apart from the wire alone, so racing them is sound. Schema-driven
formats may not be able to at all — see strede-postcard, where a bare u8
is itself varint-encoded, so "raw bytes" and "sequence of u8 varints" are
genuinely different encodings that only coincide for element values < 0x80
and diverge above that, with no wire tag to say which one a writer meant.
Racing them there would be unsound (the losing arm can run past the real
payload and surface a spurious fatal error). Only your format knows which
case it's in, so strede::utils gives you the building blocks rather than
making the choice for you:
utils::vec_via_seq/vec_via_seq_owned— always read as a plain sequence, no bytes fast path. Use this for everyTyou don't special-case.utils::vec_u8_race/vec_u8_race_owned— forT = u8, race "raw bytes" against "sequence ofu8elements"; sound only when your wire format tags the two differently.utils::vec_u8_bytes_only/vec_u8_bytes_only_owned— forT = u8, always read as raw bytes with no seq fallback ever considered; use this when your format has no wire tag to disambiguate andVec<u8>should unconditionally mean "raw bytes" (seestrede-postcard).
A typical impl special-cases T == u8 via
typeid::of::<T>() == typeid::of::<u8>() (the typeid crate is re-exported
as strede::typeid) and picks the appropriate u8 helper, falling through to
vec_via_seq/vec_via_seq_owned for every other T. See any
strede-*/src/vec.rs for a worked example.
Claim is an opaque proof-of-consumption token that carries your parser's
position forward. Each successful probe returns a new claim; that claim must be
returned through entry(), iterate(), or next() to advance the stream.
Store whatever per-step state you need (tokenizer position, buffer offset, …)
inside your Claim type.
fork() is defined on probe items: Entry, SeqEntry, MapKeyProbe,
MapValueProbe, EnumVariantProbe (and their Owned counterparts). It creates
an independent reader that shares the same underlying buffer. In the owned
family all forked readers must be polled concurrently — race them with
select_probe!. Sequentially awaiting one reader to completion before polling
another will deadlock (see the "Owned family - parallel scanning" section above
for the full explanation).
Structural accessors (MapAccess, SeqAccess, EnumAccess, StrAccess,
BytesAccess, NumberAccess and their Owned counterparts) do not implement
fork. These types may be wrapped by layout-modifier adapters generated by the
derive macro (e.g. TagAwareMap for #[strede(tag)]) which are single-use
state machines. Any concurrency inside them is handled by the map_arm /
enum_arm infrastructure.
strede::shared_buf provides a reference implementation of the multi-reader
buffer coordination contract for probe items.
If your format does not produce a particular accessor kind (e.g. no raw byte
sequences), set the corresponding associated type to Never:
type BytesChunks = strede::Never<'de, Self::Claim, Self::Error>;Never implements every trait in both families via an uninhabited match, so
the trait obligation is satisfied without dead code.
Your error type must implement strede::DeserializeError, which requires one
method: duplicate_field(field: &'static str) -> Self. This is called by
derive-generated code when a map key appears twice.
strede-json is the canonical example. The borrow family lives in
strede_json (the JsonDeserializer type); the owned family lives in
strede_json::chunked. Reading those two modules alongside the trait
definitions in strede::borrow and strede::owned is the fastest way to
understand what a complete implementation looks like.
strede is currently ~10–15x slower than serde_json in the borrow family and ~20–35x slower in the owned family on equivalent JSON deserialization workloads. Benchmarks (Criterion, Apple M-series):
| benchmark | strede/borrow | strede/owned | serde_json | borrow ratio | owned ratio |
|---|---|---|---|---|---|
| point | ~400 ns | ~850 ns | ~30 ns | ~13× | ~30× |
| log_entry | ~1.0 µs | ~3 µs | ~90 ns | ~11× | ~33× |
| rect | ~1.1 µs | ~2.5 µs | ~100 ns | ~11× | ~25× |
| deep_nested | ~4.4 µs | ~11 µs | ~500 ns | ~9× | ~22× |
The gap is structural. serde's visitor pattern compiles to a tight
synchronous dispatch loop. strede's coroutine model produces nested async
state machines - one per field, per nested type, per arm of every
select_probe!. Each layer adds state-machine overhead at compile time and movement cost at
runtime, i.e. the cost of Rust
storing each nested future into its parent state machine on every .await.
Language-level in-place initialization would allow futures to be constructed directly into their parent state machine slot rather than moved there, which may significantly reduce this overhead.
Compile times and recursion limits. Deeply-nested async state machines also
stress the compiler's recursion limit. If you encounter reached the recursion limit errors, add this to the crate:
#![recursion_limit = "256"]This is the nested future types produced by coroutine deserialization, not an artifact of macro expansion.
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).
| crate | description |
|---|---|
strede |
core traits (borrow + owned families), shared_buf module |
strede-json |
JSON deserializer backend (in-memory + chunked/streaming) |
strede-msgpack |
MessagePack deserializer backend (in-memory + chunked/streaming) |
strede-derive |
proc-macro: Deserialize, DeserializeOwned |