Output type name in serialized output?

I'm currently using serde to handle the deserialization of JSON objects on an http server written with axum. Each object represents one of a few descrete event types that get sent to another system. I have been writing the serialization code for the types by hand because I cannot figure out how to do it correctly with a serde::Serializer, because the output text needs to have an additional value that represents the event name, but serde appears to only allow you to output fields on a struct, not something like the name of a struct.

Say I have a few structs like this:


use serde::Deserialize;

mod foo {

    #[derive(Deserialize, Debug)]
    pub struct Foo {
        one: u32,
        two: u32,
    }

    #[derive(Deserialize, Debug)]
    pub struct Bar {
        one: u32,
    }

}


mod baz {

    #[derive(Deserialize, Debug)]
    pub struct Baz {
        one: u32,
    }

}

A foo::Bar { one: 33 } could be deserialized from a json string like '{"one": 33}', and needs to be serialized to a format that's something like:

---
foo.bar:
    one: 33

Currently I've been manually writing serialization code as an impl on the structs, but it comes off being repetitive boilerplate. While I think I could replace the boilerplate with a derivable trait with a procedural macro, I'd like to see if there's a more lightweight way to simplify the boilerplate first. I had thought I could do this by implementing a Serializer, but this has a couple problems.

  1. It would need to be implemented for each individual struct separately, introducing more boilerplate than it'd remove.
  2. It does not have a way to automate writing the type name in the output string.

Is there an easier way to do this, or can this only be done with a procedural macro?

Unless it's redundant and strictly for validation/self-documentation, the reason to produce a type name in serialization is so that deserialization can pick the right type to create, when it could be any one. And if you were to do that in Rust, you would need an enum.

Write an enum, serialize that, and Serde will do what you want.

#[derive(Serialize, Deserialize)]
enum Event {
    #[serde(rename = "foo.bar")]
    Bar(foo::Bar),
    #[serde(rename = "foo.baz")]
    Baz(foo::Baz),
}

This will serialize like {"foo.bar": {"one": 33}}.

3 Likes

A trick you could use is to make a wrapper type that turns some Typed<foo::Bar> into a {"foo.Bar": ...} object using std::any::type_name().

use serde::{Serialize, Serializer};
use std::collections::HashMap;

struct Typed<T>(T);

impl<T: Serialize> Serialize for Typed<T> {
    fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
        let name = std::any::type_name::<T>().replace("::", ".");
        let mut repr = HashMap::new();
        repr.insert(name, &self.0);

        repr.serialize(ser)
    }
}

(playground)

Here's an example where we use it to attach a type's name to the serialized JSON.

mod foo {
    #[derive(serde::Serialize, Debug)]
    pub struct Bar {
        pub one: u32,
    }
}

fn main() {
    println!("{}", serde_json::to_string(&Typed(42)).unwrap());
    println!("{}", serde_json::to_string(&Typed(42.to_string())).unwrap());

    println!(
        "{}",
        serde_json::to_string(&Typed(foo::Bar { one: 1 })).unwrap()
    );
}

Which prints

{"i32":42}
{"alloc.string.String":"42"}
{"playground.foo.Bar":{"one":1}}

You should be able to adapt that to deserialization code, too.

2 Likes

Note that the return value of the std::any::type_name() may change between versions of the compiler amd it's not considered as a breaking change.

4 Likes

@kpreid

And if you were to do that in Rust, you would need an enum.

That's a good point! I guess I had been avoiding modeling these with an enum because I didn't want to have to define the struct and also add it to an enum, but you're right that I have been not modelling these properly. Your example using the attr macro to rename the variants is a clever way to get to what I need without writing an serialization code too!

@Michael-F-Bryan

A trick you could use is to make a wrapper type that ...

The idea to compose parts of the problem as separate types or traits is really interesting. I worry about how stable those type names are (per @Hyeonu 's note), but it gave me an idea for an approach to try that may work but still may have some problems with the codebase I'm trying to change. By putting this detail in the serialization function that a caller uses, I can add a type constraint for a trait that always returns the name of the thing being serialized, and then use a small macro to make generating that trait easy:

pub trait BackendEvent {
    fn name(&self) -> String;
}

macro_rules! event_name {
    ($command_type:ident, $name:expr) => {
        impl BackendEvent for $command_type {
            fn name(&self) -> String {
                $name.to_string()
            }
        }
    };
}

#[derive(PartialEq, Deserialize, Serialize, Debug)]
pub struct SerializerFoo {
    pub one: u32,
}
event_name!(SerializerFoo, "Foo/SerializerFoo");

pub struct BackendSerializerByHand {
    output: String,
}

pub fn to_string<T>(value: &T) -> Result<String, Error>
where
    T: Serialize + BackendEvent,
{
    let mut serializer = BackendSerializerByHand {
        output: format!("{}\n", value.name()),
    };
    value.serialize(&mut serializer)?;
    Ok(serializer.output + "\n")
}

While I like how @kpreid 's approach more accurately models the external data in the type system, the enum will end up being hundreds of variants deep, and I like how using a trait would let a developer unfamiliar with Rust copy the pattern to implement what they need all in one place. I'll have to chew on this some more to figure out what'll work with these tradeoffs.

This was great -- thanks everyone for showing me how much room there is in solving problems creatively within serde!