Skip to Content
Course content

164: Custom Serialization Logic

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I just want one field to look different. Do I have to implement the whole Serialize trait manually?

Definitely not. Implementing Serialize by hand is a slog and, honestly, a bit of a minefield if you aren't familiar with the Serializer state machine. Most of the time, you just need a "bridge" function. Serde gives us the #[serde(serialize_with = "path")] attribute for exactly this reason.

Let's say you're working with a legacy API that expects coordinates not as an object, but as a single comma-separated string. You still want your Rust struct to have separate f64 fields for math, but the JSON needs to be "45.52,-122.67".

use serde::{Serialize, Serializer};

#[derive(Serialize)]
struct Point {
    name: String,
    #[serde(serialize_with = "serialize_coords")]
    coords: Coordinate,
}

struct Coordinate {
    lat: f64,
    lon: f64,
}

fn serialize_coords(coord: &Coordinate, serializer: S) -> Result
where
    S: Serializer,
{
    let s = format!("{},{}", coord.lat, coord.lon);
    serializer.serialize_str(&s)
}

I love this approach because it keeps the "weirdness" isolated in a small helper function. Your main data structures stay clean, and the serialization logic is decoupled from the struct definition.

When does it actually make sense to implement Serialize manually?

You should only go full manual when the structure of your serialized output is fundamentally different from the structure of your Rust type. If you're just changing a format (like the string example above), stick to attributes. But if you need to dynamically decide which fields to include based on the values of other fields, or if you're transforming a deeply nested tree into a flat list, that's when you implement the trait.

Just be warned: you'll be dealing with the Serializer trait directly. You aren't just returning a value; you're telling the serializer how to build the output. It's a bit like giving a set of assembly instructions to a robot.

impl Serialize for Coordinate {
    fn serialize(&self, serializer: S) -> Result
    where
        S: Serializer,
    {
        // Instead of a map, we're just treating the whole struct as a string
        serializer.serialize_str(&format!("{},{}", self.lat, self.lon))
    }
}

In this case, I've made Coordinate itself serialize as a string. Now, any struct that contains a Coordinate will automatically use this logic without needing the serialize_with attribute on every single field. Use this if the type is "primitive" enough that it should always be represented this way.

Can I use the same helper function for both serialization and deserialization?

Unfortunately, no. The signatures are completely different. Serialization is about taking a reference to a value and pushing it into a Serializer. Deserialization is about taking a Deserializer and pulling a value out of it.

If you've used serialize_with, you'll almost certainly need a corresponding deserialize_with. The tricky part is that the deserializer doesn't know what it's looking at until it starts parsing. You usually have to deserialize into an intermediate type (like a String) and then parse that into your final type.

use serde::{Deserialize, Deserializer};

fn deserialize_coords<'de, D>(deserializer: D) -> Result
where
    D: Deserializer<'de>,
{
    let s = String::deserialize(deserializer)?;
    let parts: Vec&str = s.split(',').collect();
    
    if parts.len() != 2 {
        return Err(serde::de::Error::custom("expected lat,lon"));
    }

    let lat = parts[0].parse().map_err(serde::de::Error::custom)?;
    let lon = parts[1].parse().map_err(serde::de::Error::custom)?;
    
    Ok(Coordinate { lat, lon })
}

It's a bit more boilerplate, but it's the only way to maintain type safety. You're essentially writing a mini-parser for that specific field.




📋 Practical Task

Exercise: Implementing a Unix Timestamp Formatter

You are integrating with an API that provides timestamps as seconds since the epoch (integers), but your Rust application uses a DateTime-like wrapper for better type safety. Your goal is to create a custom serialization pair that converts a Timestamp struct into a plain u64 during serialization, and back again during deserialization.

  • Create a struct Timestamp(u64).
  • Create a struct Event that contains a name: String and a time: Timestamp.
  • Implement a serialize_timestamp helper that serializes the Timestamp wrapper as a raw u64.
  • Implement a deserialize_timestamp helper that takes a u64 from the JSON and wraps it back into the Timestamp struct.
  • Ensure that when you serialize Event { name: "Login".into(), time: Timestamp(1672531200) }, the resulting JSON is {"name": "Login", "time": 1672531200} and NOT {"name": "Login", "time": {"0": 1672531200}}.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.