Skip to Content
Course content

188: Building a Custom Derive Macro

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was working on a massive telemetry system with dozens of different event types. Each event was a struct, and for every single one, we had to implement a ToTelemetry trait that mapped the struct fields into a specific key-value format for our database. Every time a junior dev added a new event, they'd either forget to implement the trait or, more often, make a typo in the string key that didn't get caught until the data hit production. I spent a whole Tuesday manually auditing 60 structs. I remember staring at the screen thinking, "The compiler already knows the names of these fields; why am I typing them again?"

That's where custom derive macros come in. They allow you to write code that generates other code at compile time. Instead of manually implementing a trait, you just slap #[derive(MyTrait)] on your struct, and the macro handles the boilerplate. It’s essentially a plugin for the Rust compiler.

The Machinery of syn and quote

Before you write a single line of macro logic, you need to understand that a derive macro doesn't actually "see" your Rust code as text. It sees it as a TokenStreamβ€”a sequence of tokens like struct, User, {, and String. Trying to manipulate this raw stream is a nightmare. That's why we almost always use two crates: syn and quote.

syn is the parser. It takes that raw TokenStream and turns it into a Syntax Tree (AST) that we can actually work with. It lets us ask questions like, "Is this a struct? What are its field names? Are they public?" quote is the inverse; it allows us to write Rust-like code in a macro and "quote" it back into a TokenStream that the compiler can then integrate into the final binary.

One critical detail: derive macros must live in their own crate. You cannot define a proc-macro in the same crate where you use it. In your Cargo.toml, you have to explicitly tell Rust this is a procedural macro library by adding proc-macro = true under the [lib] section.

Mapping Struct Fields to Implementation

Let's look at how we'd build a Loggable derive macro. The goal is to automatically implement a log_me method that prints the name of every field and its value. Here is how the implementation logic actually flows:


use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};

#[proc_macro_derive(Loggable)]
pub fn loggable_derive(input: TokenStream) -> TokenStream {
    // Parse the input tokens into a syntax tree
    let input = parse_macro_input!(input as DeriveInput);
    
    // Get the name of the struct (e.g., "User")
    let name = input.ident;

    // We only care about structs with named fields for this example
    let fields = if let syn::Data::Struct(syn::DataStruct { fields: syn::Fields::Named(fields), .. }) = input.data {
        fields.named
    } else {
        panic!("Loggable can only be derived for structs with named fields");
    };

    // Generate the actual implementation code
    let field_logs = fields.iter().map(|f| {
        let name = &f.ident;
        quote! {
            println!("{}: {:?}", stringify!(#name), self.#name);
        }
    });

    let expanded = quote! {
        impl Loggable for #name {
            fn log_me(&self) {
                println!("Logging struct {}:", stringify!(#name));
                #(#field_logs)*
            }
        }
    };

    // Convert the quote back into a TokenStream
    TokenStream::from(expanded)
}

Notice the #(#field_logs)* syntax. That's quote's way of handling iteration. Since field_logs is an iterator, the repetition operator expands it into a sequence of statements. I personally find this the most "magical" part of the process, but once you get used to it, it's incredibly powerful. You're essentially templating Rust code using the actual AST as your data source.

Keep in mind that because this happens at compile time, if your macro panics (like the panic! call above), the user will see that error message as a compilation error. It's a bit jarring, but it's the most direct way to enforce constraints on who can use your derive macro.




πŸ“‹ Practical Task

Build a "JsonKey" Derive Macro

Your task is to create a custom derive macro called JsonKey. Instead of logging the values, this macro should implement a trait called JsonSerializable which has a single method: fn to_json_keys(&self) -> Vec<String>.

The to_json_keys method should return a vector containing the names of all the fields in the struct as strings. For example, if you have a struct User { username: String, email: String }, calling to_json_keys() should return vec!["username".to_string(), "email".to_string()].

Requirements:

  • Create a separate crate for the macro with proc-macro = true.
  • Use syn to extract the field identifiers.
  • Use quote to generate the Vec of strings.
  • Ensure the macro only works on structs with named fields; otherwise, provide a descriptive compile-time panic.
  • Test it on a struct with at least three different fields.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.