#[derive(Debug)] on serde steroids

Posted on:

In this post I'd like to introduce a serdebug helper which is a drop-in replacement for #[derive(Debug)] with some of the advanced features that serde can provide.

Yes, it's meant to be read as "ser"+"debug" (serialise to Debug) or "serde"+"debug" (Debug with serde) and not the way you thought ;).

Why would I want it?

Often enough, in Rust I've ran into situations where I wanted to make Debug representations more readable by hiding certain fields (like PhantomData or similar markers that don't contain any useful information) or providing custom representation for some of them (which is especially hard in case of third-party types).

Unfortunately, what this usually means is a choice between giving up on auto-derived representation completely and implementing Debug for your types manually, which might be quite verbose for large structures or enums, or wrapping types into intermediate newtype, which is a popular pattern in Rust lands, but not very convenient one (IMO), as it pollutes every usage side and/or requires reimplementing all traits you care about on top of the wrapper (often manually, unless you are lucky and it's easy to #[derive(...)] with existing crates).

When I looked for solutions on crates.io, surely, I found that some of these issues were already tackled by others - for example, you can use debug_stub_derive to provide custom formatting function for certain fields or derivative to also unwrap newtypes or rename fields (the latter also has a number of customisations for other standard traits like Hash, Clone, PartialEq, etc., so definitely check it out if you ever wanted to customise them too).

What bothered me however, is that they still felt not flexible enough, and at the same time I already use serde for most of my types which provides these and many more customisations out of the box, as it has to deal with all sorts of output formats, and usually I'd just want my debug representation to use the same names and fields as I'm already serialising, expect in a more human-readable format that Debug provides.

So then I thought, what is Debug if not just another serialisation format? Moreover, looking at the advanced API, it's easy to see how DebugMap is similar to SerializeMap or DebugTuple to SerializeTuple and so on.

So yes, I've decided to wrap all these std::fmt structures into newtypes that implement serde::ser traits, and then to implement main Serializer interface that would call out to them simulating exact same output that #[derive(Debug)] would normally produce for you with its own auto-generated code, unless you enabled some of the serde-specific attributes to control serialisation behaviour.

How do I use it?

On any types where you already use #[derive(Debug)], you can replace with #[derive(SerDebug)] and add serde-provided #[derive(Serialize)] if you don't already have it.

As mentioned above, by default the output is aimed to exactly match the built-in derive, and that's not very interesting to observe, so instead, let's add a bunch of serde attributes to customise it (usually you won't need that many, this is done purely for demonstration purposes):

#[derive(Serialize, SerDebug)]
pub enum MyEnum {
    // renaming items works as expected
    #[serde(rename = "AAAAAAA!!!")]
    A,

    B(u32),

    C { flag: bool },
}

#[derive(Serialize, SerDebug)]
// so does bulk rename on containers
#[serde(rename_all = "PascalCase")]
pub struct MyStruct {
    number: u32,

    my_enum: Vec<MyEnum>,

    // we might want to hide some items from the output
    #[serde(skip_serializing)]
    hidden: bool,

    // or override serialisation for otherwise verbose wrappers or
    // third-party types that don't implement `Debug` and/or `Serialize`
    #[serde(serialize_with = "custom_serialize")]
    custom_type: CustomType,
}

fn custom_serialize<S: serde::Serializer>(value: &CustomType, ser: S) -> Result<S::Ok, S::Error> {
    use serde::Serialize;

    value.0.serialize(ser)
}

Now, if we try to define a sample MyStruct value

let s = MyStruct {
    number: 42,
    my_enum: vec![MyEnum::A, MyEnum::B(10), MyEnum::C { flag: true }],
    hidden: true,
    custom_type: CustomType(20),
};

and print its debug representation as we normally would

println!("{:#?}", s);

you will see that the output still resembles Debug but has our customisations applied:

MyStruct {
    Number: 42,
    MyEnum: [
        AAAAAAA!!!,
        B(
            10
        ),
        C {
            flag: true
        }
    ],
    CustomType: 20
}

Why you might not want it

serde, especially serde_derive, might be a heavy dependency to add to your project as it sometimes adds non-trivial overhead to compilation times.

On average-sized and larger projects, chances are that you already have it somewhere in your tree and use for other (de)serialisation targets. In that case the difference should be negligible as now you will need to compile just one more custom derive (which you will need anyway if you decided to customise your output with one of the helper crates). However, if you do not use serde yet, you might be better off with derivative helper - I haven't personally compared compilation times, but I do think that it will be faster than all of serde + serde_derive + serdebug combined.


This is it for now, but if you find any bugs or have improvement suggestions which are specific to serdebug and not serde in general, please do report them at github issue tracker.


More posts: