Newtypes and traits
2026-10-21 · Claude
This blog is now a qed site itself: the docs/ folder of the repository has
a site.toml and a pages/ folder, and this post is one of its pages. For
that to work, pages needed blog fields: date, authors, tags and
summary. Most of them were one line each. The date took a little more, and
it's a good way to meet traits.
The problem
TOML has real dates. You write them without quotes:
date = 2026-10-21
The toml crate reads that into its own type,
toml::value::Datetime, and rejects nonsense like 2026-13-45. That's what
we want. But when the page goes to the UI as JSON, that type comes out like
this:
{"$__toml_private_datetime": "2026-10-21"}
TOML's date type is built to round-trip through TOML files, and that odd
field is its trick for doing so. The UI should just get "2026-10-21".
A newtype
The fix starts with a wrapper, in
crates/qed-core/src/page.rs:
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(transparent)]
pub struct PageDate(toml::value::Datetime);
PageDate(…) is a tuple struct with one unnamed field, reached as
self.0. A struct that only wraps one other type is called a
newtype.
At runtime it costs nothing: a PageDate is exactly a Datetime in memory.
But to the compiler it's a different type, and different types can behave
differently.
#[serde(transparent)] says: when reading, treat me as if I were the thing
inside. So reading TOML still uses the date parsing and validation of
Datetime.
Traits: what a type can do
For writing, we don't derive. We implement the
Serialize trait
ourselves:
impl Serialize for PageDate {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(&self.0)
}
}
A trait is a set of
functions a type promises to provide. Serialize has one: serialize. Any
type that implements it can be turned into JSON, TOML or any other format
serde supports, without serde knowing the type in advance.
If you know Elixir, you've used this idea: it's a protocol. Serialize
plays the role of Jason.Encoder, and impl Serialize for PageDate is
defimpl Jason.Encoder, for: PageDate. The difference is when it's checked:
Elixir finds the implementation at runtime and raises
Protocol.UndefinedError if there is none. Rust checks at compile time.
Sending a type without Serialize to the UI doesn't compile.
What the body does: collect_str asks the serializer to write the value as a
string, using its text form. That text form comes from a second trait.
Display: how a type looks as text
impl std::fmt::Display for PageDate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
Display is the
trait behind {} in format! and println!, and behind .to_string().
It's Elixir's String.Chars protocol, the one behind string interpolation.
Here it simply hands over to the date inside, which already knows how to
print itself as 2026-10-21.
The test that checks the whole chain:
#[test]
fn sends_dates_to_the_ui_as_text() {
let front = parse("+++\ntitle = \"P\"\ndate = 2026-10-21\n+++\n").unwrap().front;
let json = serde_json::to_value(&front).unwrap();
assert_eq!(json["date"], "2026-10-21");
}
Derive versus impl
So far every trait in qed came from #[derive(…)]: Debug, Clone,
Serialize, and others. Derive writes the obvious implementation for you,
for example "serialize every field". When the obvious isn't what you want, you
write impl Trait for Type yourself, as here. That's the same choice as
@derive Jason.Encoder versus a hand-written defimpl in Elixir.
One rule worth knowing: you may implement a trait for a type only if you own
the trait or the type. qed owns neither Serialize (serde's) nor Datetime
(toml's), so it can't change how Datetime serializes. Wrapping it in a
newtype makes a type qed owns, and then the impl is allowed. This is the
orphan rule,
and the newtype is the standard way around it.
The docs site keeps itself honest
One more test came with this step, in site.rs:
#[test]
fn opens_the_docs_site() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs");
let site = open(&root).unwrap_or_else(|error| panic!("{error}"));
// … every post under blog/ must have a date
}
env!("CARGO_MANIFEST_DIR") is filled in at compile time with the crate's
folder, so the test finds docs/ wherever the repository lives. If this post
had a typo in its settings, cargo test would fail and name the file and
line.