Serde Traits and the #[serde(with = ...)] Override

What we just wrote in serde_helpers.rs uses several deep Rust trait concepts. This note explains them.

The Code in Question

pub mod biguint_string {
    use num_bigint::BigUint;
    use serde::{Deserialize, Deserializer, Serializer};

    pub fn serialize<S: Serializer>(v: &BigUint, ser: S) -> Result<S::Ok, S::Error> {
        ser.serialize_str(&v.to_string())
    }

    pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<BigUint, D::Error> {
        let s = String::deserialize(de)?;
        BigUint::parse_bytes(s.as_bytes(), 10)
            .ok_or_else(|| serde::de::Error::custom("invalid BigUint string"))
    }
}

Then in Transaction:

pub struct Transaction {
    // ...
    #[serde(with = "crate::serde_helpers::biguint_string")]
    pub challenge_e: BigUint,
}

What’s happening here? Multiple trait concepts at once.

Concept 1: Serializer / Deserializer Are Traits, Not Concrete Types

Serde is format-agnostic. The same struct can serialize to JSON, bincode, YAML, MessagePack — depending on which Serializer is plugged in.

fn serialize<S: Serializer>(v: &BigUint, ser: S) -> Result<S::Ok, S::Error> {
    //         ^^^^^^^^^^^^   generic over any Serializer
    ser.serialize_str(&v.to_string())
}

When called from JSON, S is serde_json::Serializerserialize_str writes "223" to JSON. When called from bincode, S is bincode::Serializerserialize_str writes a length-prefixed UTF-8 string in binary.

We don’t know or care which one — we just say “give me a Serializer”.

This is static dispatch with generics: monomorphized at compile time, zero runtime overhead.

Concept 2: Associated Types (S::Ok, S::Error)

Notice the return type:

Result<S::Ok, S::Error>

Trait Serializer has associated types for what it produces and what errors it raises:

trait Serializer {
    type Ok;
    type Error: Error;
    // ... methods that return Result<Self::Ok, Self::Error>
}

For serde_json::Serializer, Ok = () and Error = serde_json::Error. For bincode::Serializer, those are different types.

We write S::Ok, S::Error as opaque placeholders — “whatever this Serializer produces, that’s our return type.”

Associated types vs generics:

Concept 3: Lifetimes ('de)

fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<BigUint, D::Error>

The 'de lifetime means: “the deserializer borrows data with lifetime 'de, and any &str we pull from it must live at least that long.”

Why? Some serde formats deserialize without copying (zero-copy). For example, &str taken from a JSON buffer points into that buffer. The lifetime 'de ties the deserialized data’s lifetime to the input buffer’s lifetime.

We’re not using zero-copy here — we own the String we get back. But the lifetime parameter is still required because Deserializer<'de> is a generic trait parameterized by lifetime.

Reading shorthand:

This is the most “Rust-flavored” piece. Don’t try to memorize it; recognize the shape and move on.

Concept 4: Trait Bounds and ?Sized

We also use String::deserialize(de). That’s calling the Deserialize trait’s method:

trait Deserialize<'de>: Sized {
    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error>;
}

String implements Deserialize, so String::deserialize(de) works. We then ask BigUint::parse_bytes to parse it.

The Sized bound on Deserialize says: “you must produce a value of known size at compile time” — which String and BigUint satisfy.

Concept 5: The #[serde(with = ...)] Override

This is the killer feature.

By default, #[derive(Serialize)] on Transaction calls BigUint::serialize for challenge_e. BigUint::serialize (the default impl) writes it as Vec<u32> of internal limbs — [223] in JSON.

#[serde(with = "crate::serde_helpers::biguint_string")] overrides that: serde looks for two functions named serialize and deserialize in that module path, and uses those for this field instead of the default trait impl.

Effectively, a per-field codec replacement:

pub struct Transaction {
    pub from: u32,         // uses u32's default Serialize
    pub to: u32,           // uses u32's default Serialize
    #[serde(with = "...")]
    pub challenge_e: BigUint, // uses our custom functions instead
}

This is not trait override — BigUint’s Serialize impl still exists unchanged. It’s a serde-derive-time decision: “for this field, route through this module’s serialize/deserialize instead of calling the trait.”

Why It’s Not Inheritance

Coming from OOP (Java, Python) you might think “BigUint is overriding its serialize method.” That’s not what’s happening. Rust doesn’t have method overriding in that sense.

What’s happening:

Same BigUint type, two different places where it might be used:

Same value, two formats. Per-field codec choice.

Concept 6: Module Path as Codec Identifier

#[serde(with = "crate::serde_helpers::biguint_string")]

The string is a Rust path to a module. Serde’s macro looks inside that module for two functions:

If you pointed at a function instead of a module, serde wouldn’t find both halves.

The “module-as-codec” pattern is idiomatic in serde:

You can have many helpers:

pub mod biguint_string { ... }
pub mod biguint_hex { ... }     // could write 0xff format
pub mod biguint_decimal_padded { ... }

And a struct can mix them:

struct Foo {
    #[serde(with = "biguint_string")]
    a: BigUint,
    #[serde(with = "biguint_hex")]
    b: BigUint,
}

Putting It All Together

pub fn serialize<S: Serializer>(v: &BigUint, ser: S) -> Result<S::Ok, S::Error> {
    ser.serialize_str(&v.to_string())
}

Reading this declaration:

pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<BigUint, D::Error> {
    let s = String::deserialize(de)?;
    BigUint::parse_bytes(s.as_bytes(), 10)
        .ok_or_else(|| serde::de::Error::custom("invalid BigUint string"))
}

Reading:

Why Rust’s Trait System Makes This Possible

In dynamic languages, you’d swap behavior by monkey-patching or subclassing. In Rust:

ConceptRust mechanism
Polymorphism over output formatGeneric S: Serializer
Format-specific typesAssociated types S::Ok, S::Error
Borrow vs own deserialized dataLifetime 'de
Per-field codec selection#[serde(with = "...")] macro attribute
Compile-time dispatchMonomorphization

The result: zero-runtime-overhead format flexibility. Your generated code is as fast as if you’d hand-written every codec.

Mental Model

When you see #[serde(with = "path")]:

“For this field only, route serialization through path::serialize and deserialization through path::deserialize instead of using the field’s default impl.”

When you see <S: Serializer>:

“Be polymorphic over format. The compiler will instantiate this function once per format used.”

When you see 'de:

“Lifetime tying deserialized borrows to the input buffer.”

When you see S::Ok:

“The associated ‘success type’ of whatever Serializer was passed in.”

That’s enough to read 95% of serde-using Rust code.