The reflex of deriving `serde` traits
May 24, 2026
serde is a great library, and a big part of what makes it great is how easy it is to use:
just derive Serialize/Deserialize and that’s it. But because
it takes zero effort to add those derives to an existing #[derive(Debug, Clone, ...)],
types often end up implementing Serialize/Deserialize accidentally, by reflex. Once some
other piece of code (or another system entirely) starts relying on the format that this derive
produces, there’s no easy way back: a type can only have one impl of a given trait, so the
derived format is the only one the type itself produces.
Let’s say we have some struct that represents a core domain component. We want to store its
representation as jsonb (or similar) in our database, and we also want to serve it to the
frontend. Now there are two consumers of the struct’s representation, and if we just derive
Serialize onto it we have only one way to represent it. At that point we might spend real
effort trying to come up with a representation that fits both needs, for no reason other than
the mental model of “single derive, single repr”.
The textbook approach is a dedicated type per consumer: a DomainObjectFe for the frontend,
a DomainObjectDb for the database, each with its own impl From<DomainObject> and its own
(De)Serialize derives:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
...
| mod core {
#[derive(Clone)]
pub struct DomainObject {
pub name: String,
pub items: Vec<DomainSubObject>,
}
#[derive(Clone)]
pub struct DomainSubObject {
pub id: u64,
pub label: String,
}
}
mod fe {
use super::core;
use serde::Serialize;
#[derive(Serialize)]
pub struct DomainObjectFe {
fe_name: String,
fe_items: Vec<DomainSubObjectFe>,
}
#[derive(Serialize)]
struct DomainSubObjectFe {
fe_id: u64,
fe_name: String,
}
impl From<core::DomainSubObject> for DomainSubObjectFe {
fn from(value: core::DomainSubObject) -> Self {
Self {
fe_id: value.id,
fe_name: value.label,
}
}
}
impl From<core::DomainObject> for DomainObjectFe {
fn from(value: core::DomainObject) -> Self {
Self {
fe_name: value.name,
fe_items: value.items.into_iter().map(Into::into).collect(),
}
}
}
}
mod db {
use super::core;
use serde::Serialize;
#[derive(Serialize)]
pub struct DomainObjectDb {
db_name: String,
db_items: Vec<DomainSubObjectDb>,
}
#[derive(Serialize)]
struct DomainSubObjectDb {
db_id: u64,
db_label: String,
}
impl From<core::DomainSubObject> for DomainSubObjectDb {
fn from(value: core::DomainSubObject) -> Self {
Self {
db_id: value.id,
db_label: value.label,
}
}
}
impl From<core::DomainObject> for DomainObjectDb {
fn from(value: core::DomainObject) -> Self {
Self {
db_name: value.name,
db_items: value.items.into_iter().map(Into::into).collect(),
}
}
}
}
|
For complex enough types this approach pays a price at conversion: an inner collection, for
example, may have to be reallocated. In some applications this overhead is negligible, but in
others it’s a noticeable penalty. There’s also psychological friction: it just feels wrong
to pay the performance price when having serde traits derived on the core type would avoid
the conversion cost.
Another way to have separate representations for a given “core” type is to use custom
(de)serializers. Unfortunately this approach works well only for “simple” types: basic
enums without inner fields and newtype structs wrapping a simple “value-like” type. For anything
more complex, writing a custom (de)serializer gets complicated very quickly (which is why
serde provides the derives in the first place).
For example struct UnixTimestamp(u64); is a “simple” type that represents a unix timestamp with
nanosecond precision. Let’s say we want to send it to the frontend in RFC 3339 format and store it
in the database as milliseconds. We could of course apply the textbook approach here too.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
...
42
43
44
...
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
...
72
...
| mod core {
#[derive(Clone)]
pub struct UnixTimestamp(pub u64);
#[derive(Clone)]
pub struct DomainObject {
pub name: String,
pub unix_ts: UnixTimestamp,
}
}
mod fe {
use super::core;
use chrono::DateTime;
use serde::Serialize;
#[derive(Serialize)]
pub struct DomainObjectFe {
name: String,
unix_ts: UnixTimestampFe,
}
#[derive(Serialize)]
struct UnixTimestampFe(String);
impl From<core::UnixTimestamp> for UnixTimestampFe {
fn from(value: core::UnixTimestamp) -> Self {
let dt = DateTime::from_timestamp_nanos(value.0 as i64);
Self(dt.to_rfc3339())
}
}
}
mod db {
use serde::Serialize;
#[derive(Serialize)]
pub struct DomainObjectDb {
name: String,
unix_ts: UnixTimestampDb,
}
#[derive(Serialize)]
struct UnixTimestampDb(u64);
impl From<core::UnixTimestamp> for UnixTimestampDb {
fn from(value: core::UnixTimestamp) -> Self {
Self(value.0 / 1_000_000)
}
}
}
|
Instead of introducing new per-consumer types, the core type itself can provide reusable custom
(de)serializers, and unlike a (De)Serialize impl, it can have many of them. Each consumer still
picks the representation it wants.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
...
52
53
54
55
56
57
58
59
60
61
62
...
72
73
74
...
77
78
79
80
81
82
83
84
85
86
87
...
97
...
| mod core {
use chrono::DateTime;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Clone)]
pub struct UnixTimestamp(pub u64);
#[derive(Clone)]
pub struct DomainObject {
pub name: String,
pub unix_ts: UnixTimestamp,
}
pub fn serialize_unix_ts_rfc3339<S: Serializer>(
value: &UnixTimestamp,
serializer: S,
) -> Result<S::Ok, S::Error> {
let dt = DateTime::from_timestamp_nanos(value.0 as i64);
dt.to_rfc3339().serialize(serializer)
}
pub fn deserialize_unix_ts_rfc3339<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<UnixTimestamp, D::Error> {
let s = String::deserialize(deserializer)?;
let dt = DateTime::parse_from_rfc3339(&s).map_err(serde::de::Error::custom)?;
let ns = dt
.timestamp_nanos_opt()
.filter(|&n| n >= 0)
.ok_or_else(|| serde::de::Error::custom("timestamps out of range"))?;
Ok(UnixTimestamp(ns as u64))
}
pub fn serialize_unix_ts_millis<S: Serializer>(
value: &UnixTimestamp,
serializer: S,
) -> Result<S::Ok, S::Error> {
(value.0 / 1_000_000).serialize(serializer)
}
pub fn deserialize_unix_ts_millis<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<UnixTimestamp, D::Error> {
let ms = u64::deserialize(deserializer)?;
Ok(UnixTimestamp(ms * 1_000_000))
}
}
mod fe {
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct DomainObjectFe {
name: String,
#[serde(
serialize_with = "core::serialize_unix_ts_rfc3339",
deserialize_with = "core::deserialize_unix_ts_rfc3339"
)]
unix_ts: core::UnixTimestamp,
}
}
mod db {
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct DomainObjectDb {
name: String,
#[serde(
serialize_with = "core::serialize_unix_ts_millis",
deserialize_with = "core::deserialize_unix_ts_millis"
)]
unix_ts: core::UnixTimestamp,
}
}
|
Notice how consumer types still use the core UnixTimestamp type unchanged, relying on
specific (de)serializers also provided by the core module. A slight ergonomic step up is
using the #[serde(with = "...")] approach, which reduces the clunkiness of the serde field
attribute.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
...
61
62
63
64
65
66
67
68
...
78
79
80
...
83
84
85
86
87
88
89
90
...
100
...
| mod core {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Clone)]
pub struct UnixTimestamp(pub u64);
#[derive(Clone)]
pub struct DomainObject {
pub name: String,
pub unix_ts: UnixTimestamp,
}
pub mod unix_ts_rfc3339 {
use super::*;
use chrono::DateTime;
pub fn serialize<S: Serializer>(
value: &UnixTimestamp,
serializer: S,
) -> Result<S::Ok, S::Error> {
let dt = DateTime::from_timestamp_nanos(value.0 as i64);
dt.to_rfc3339().serialize(serializer)
}
pub fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<UnixTimestamp, D::Error> {
let s = String::deserialize(deserializer)?;
let dt = DateTime::parse_from_rfc3339(&s).map_err(serde::de::Error::custom)?;
let ns = dt
.timestamp_nanos_opt()
.filter(|&n| n >= 0)
.ok_or_else(|| serde::de::Error::custom("timestamps out of range"))?;
Ok(UnixTimestamp(ns as u64))
}
}
pub mod unix_ts_millis {
use super::*;
pub fn serialize<S: Serializer>(
value: &UnixTimestamp,
serializer: S,
) -> Result<S::Ok, S::Error> {
(value.0 / 1_000_000).serialize(serializer)
}
pub fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<UnixTimestamp, D::Error> {
let ms = u64::deserialize(deserializer)?;
Ok(UnixTimestamp(ms * 1_000_000))
}
}
}
mod fe {
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct DomainObjectFe {
name: String,
#[serde(with = "core::unix_ts_rfc3339")]
unix_ts: core::UnixTimestamp,
}
}
mod db {
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct DomainObjectDb {
name: String,
#[serde(with = "core::unix_ts_millis")]
unix_ts: core::UnixTimestamp,
}
}
|
And yet another step up is using the serde_with library. It also allows defining
custom (de)serializers with its SerializeAs/DeserializeAs
traits, and it allows for type composition which can’t really be achieved with serde’s
(de)serialize_with/with attributes. For example, we can easily custom-(de)serialize an
optional unix timestamp.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
...
65
66
67
68
69
70
71
72
73
74
...
84
85
86
...
89
90
91
92
93
94
95
96
97
98
...
108
...
| mod core {
use chrono::DateTime;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_with::{DeserializeAs, SerializeAs};
#[derive(Clone)]
pub struct UnixTimestamp(pub u64);
#[derive(Clone)]
pub struct DomainObject {
pub name: String,
pub unix_ts: UnixTimestamp,
}
pub struct UnixTsAsRfc3339;
impl SerializeAs<UnixTimestamp> for UnixTsAsRfc3339 {
fn serialize_as<S: Serializer>(
source: &UnixTimestamp,
serializer: S,
) -> Result<S::Ok, S::Error> {
let dt = DateTime::from_timestamp_nanos(source.0 as i64);
dt.to_rfc3339().serialize(serializer)
}
}
impl<'de> DeserializeAs<'de, UnixTimestamp> for UnixTsAsRfc3339 {
fn deserialize_as<D: Deserializer<'de>>(
deserializer: D,
) -> Result<UnixTimestamp, D::Error> {
let s = String::deserialize(deserializer)?;
let dt = DateTime::parse_from_rfc3339(&s).map_err(serde::de::Error::custom)?;
let ns = dt
.timestamp_nanos_opt()
.filter(|&n| n >= 0)
.ok_or_else(|| serde::de::Error::custom("timestamps out of range"))?;
Ok(UnixTimestamp(ns as u64))
}
}
pub struct UnixTsAsMillis;
impl SerializeAs<UnixTimestamp> for UnixTsAsMillis {
fn serialize_as<S: Serializer>(
source: &UnixTimestamp,
serializer: S,
) -> Result<S::Ok, S::Error> {
(source.0 / 1_000_000).serialize(serializer)
}
}
impl<'de> DeserializeAs<'de, UnixTimestamp> for UnixTsAsMillis {
fn deserialize_as<D: Deserializer<'de>>(
deserializer: D,
) -> Result<UnixTimestamp, D::Error> {
let ms = u64::deserialize(deserializer)?;
Ok(UnixTimestamp(ms * 1_000_000))
}
}
}
mod fe {
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
#[serde_as]
#[derive(Serialize, Deserialize)]
pub struct DomainObjectFe {
name: String,
#[serde_as(as = "Option<core::UnixTsAsRfc3339>")]
unix_ts: Option<core::UnixTimestamp>,
}
}
mod db {
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
#[serde_as]
#[derive(Serialize, Deserialize)]
pub struct DomainObjectDb {
name: String,
#[serde_as(as = "core::UnixTsAsMillis")]
unix_ts: core::UnixTimestamp,
}
}
|
Custom (de)serializers (via both serde_with and serde itself) can override a derived
(De)Serialize impl, which is useful if a type already has one: existing code that relies on
the derived format keeps using it, while a new consumer can use a custom (de)serializer.
So a “core” type likely shouldn’t be (de)serializable at all, and reflexively slapping
Serialize/Deserialize derives onto one bypasses that question in the first place.
When its representation does need to reach a consumer, a dedicated per-consumer type
is the general answer, but for simple cases, custom (de)serializers might be the more
ergonomic alternative.
Full accompanying source code can be found here. Built with rustc 1.95.0.
Library versions used: serde 1.0.228, serde_with 3.20.0.