Building a Custom derive Macro in Rust: Understanding Procedural Macros by Building…
One of my favorite features in Rust is procedural macros. They’re basically Rust’s way of saying:
Building a Custom derive Macro in Rust: Understanding Procedural Macros by Building ByteSerializable

AI Generated — OpenAI
One of my favorite features in Rust is procedural macros. They’re basically Rust’s way of saying:
Stop writing the same code over and over. Let the compiler do it
I recently had an excuse to finally dive into them.
The Backstory
I’m currently building an in-memory datastore.
Initially, I considered using gRPC for communication because…well…everyone does. But after profiling things, I realized I didn’t actually need everything gRPC provides. I just wanted an efficient binary protocol with minimal overhead. And for that, I needed one serializer and deserializer for the data.
serde was another obvious choice. It's fantastic.
But I had one annoying constraint:
Binary size.
I wanted something tiny, fast, and purpose-built.
So instead of pulling in a large serialization framework, I decided to build a very small serialization trait along with a custom derive macro that generates the implementation automatically.
That little experiment taught me far more about Rust’s macro system than reading documentation ever did.
Traits and Procedural Macros Are Best Friends
One thing finally clicked for me.
Traits and procedural macros aren’t competing features.
They complement each other.
The trait defines what a type should do.
pub trait ByteSerializable: Sized {
fn byte_serialize(&self, out: &mut Vec<u8>);
fn byte_deserialize(input: &mut &[u8]) -> Result<Self, String>;
}
The procedural macro simply writes the boring implementation for you.
Instead of manually writing:
impl ByteSerializable for User {
...
}
You get to write:
#[derive(ByteSerializable)]
struct User {
name: String,
id: u32,
}
Much nicer.
My Biggest Misconception
When I first started learning Rust, I thought procedural macros somehow “understood” traits.
They don’t. This is probably the biggest mental model to fix. Procedural macros run before Rust performs type checking. That means the macro only sees the syntax tree (AST).
Given this:
#[derive(ByteSerializable, Debug)]
struct TestStruct {
a: u8,
b: u32,
c: String,
}
the macro receives something like:
“Here’s a struct named
TestStructwith three fields."
That’s it. It doesn’t know whether u32 implements your trait. It doesn’t know whether String implements your trait. It doesn’t even know if ByteSerializable exists. The macro’s only job is generating Rust code. Later, the compiler checks whether that generated code is actually valid. Once I understood this, procedural macros suddenly became much less mysterious.
What Actually Gets Generated?
Imagine writing:
#[derive(ByteSerializable)]
struct User {
name: String,
id: u32,
}
The macro expands into something roughly like this:
impl ByteSerializable for User {
fn byte_serialize(&self, out: &mut Vec<u8>) {
...
}
fn byte_deserialize(input: &mut &[u8]) -> Result<Self, String> {
...
}
}
No magic. Just generated Rust code.
Parsing the Struct
The first step inside the macro is parsing the user’s type.
let fields = match input.data {
Data::Struct(s) => match s.fields {
Fields::Named(f) => f.named,
_ => panic!("Only named structs supported"),
},
_ => panic!("Only structs supported"),
};
This simply says:
- only structs
- only named fields
- no tuple structs
- no enums
- no unions
Eventually you should replace those panic!() calls with proper compiler diagnostics.
Instead of crashing, produce an error pointing exactly to the offending code.
return syn::Error::new_spanned(
field,
"Expected a named field",
)
.to_compile_error()
.into();
Your users (including future you) will thank you.
Generating the Implementation
Once we have the fields, generating code is surprisingly straightforward.
For serialization:
<#ty as byteser::ByteSerializable>::byte_serialize(
&self.#ident,
out,
);
For deserialization:
#ident:
<#ty as byteser::ByteSerializable>::byte_deserialize(input)?
Then quote! stitches everything together into a complete implementation.
Honestly, quote! feels like writing Rust that writes Rust, which is both confusing and incredibly satisfying.
Why Two Crates?
A common workspace layout looks like this:
byte-ser/
├── byteser/
│ └── src/lib.rs
├── byteser_derive/
│ └── src/lib.rs
└── example/
└── src/main.rs
byteser
- contains the trait
- contains implementations for primitive types
byteser_derive
- contains only procedural macros
Your Cargo.toml becomes:
[workspace]
members = [
"byteser",
"byteser_derive",
]
And don’t forget:
[lib]
proc-macro = true
along with:
proc-macro2
quote
syn
These three crates are basically the holy trinity of Rust procedural macros.
Primitive Types Still Need Implementations
The derive macro only generates code. It doesn’t magically know how to serialize a u32, String, or Vec<T>.
You still need implementations like:
impl ByteSerializable for u32 { ... }
impl ByteSerializable for String { ... }
The derive macro simply stitches those implementations together.
Think of it like LEGO. The primitive implementations are the bricks. The derive macro assembles them into bigger structures.
Lessons Learned
A few things I’d recommend if you’re building your own derive macros:
- Keep traits and macros in separate crates to avoid dependency cycles.
- Validate unsupported inputs early.
- Prefer
syn::Erroroverpanic!. - Split parsing, validation, and code generation into helper functions.
Did I follow all of those while building this?
Absolutely not. Future me can clean it up.
Building a procedural macro sounded intimidating at first.
In reality, it’s mostly:
- parsing Rust syntax with
syn - generating Rust code with
quote - letting the compiler do the rest
Once the mental model clicks, procedural macros become much less magical and much more practical.
They’re an incredibly elegant way to eliminate repetitive boilerplate while keeping Rust’s type safety intact.
If you’re curious, check out the full implementation here:
GitHub: https://github.com/dipghoshraj/byte-ser
Hopefully this saves you a few hours of confusion — and maybe convinces you to write your first derive macro instead of your hundredth manual impl.
메타데이터
- post_id
- 8d9d9303a05c
- slug
- building-a-custom-derive-macro-in-rust-understanding-procedural-macros-by-building-8d9d9303a05c
- url
- https://medium.com/@dipghoshraj/building-a-custom-derive-macro-in-rust-understanding-procedural-macros-by-building-8d9d9303a05c
- canonical_url
- https://medium.com/@dipghoshraj/building-a-custom-derive-macro-in-rust-understanding-procedural-macros-by-building-8d9d9303a05c
- author_url
- https://medium.com/@dipghoshraj
- status
- ok
- fetched_at
- 2026-07-18 20:46:04