Dmitrii Aleksandrov

Destructure as a Reminder

Posted on 4 mins

Rust Tech

Here’s a Rust trick that I use to save time and prevent bugs as the codebase evolves. I’m probably not the first one to invent it.

Problem statement

Say, you have a struct which is “a bag of fields”, like a set of filters on an e-commerce website:

use serde::Deserialize;

#[derive(Deserialize)]
struct ProductFilter {
    pub price_from: Option<usize>,
    pub price_to: Option<usize>,
}

You have an operation that needs to somehow “handle” every field:

use sea_orm::*;
use sea_query::*;

impl IntoCondition for ProductFilter {
    pub fn into_condition(self) -> Condition {
        let mut cond = Condition::all();
        if let Some(price_from) = self.price_from {
            cond.add(product::Column::Price.gte(price_from))
        }
        if let Some(price_to) = self.price_to {
            cond.add(product::Column::Price.lte(price_to))
        }
        cond
    }
}

Two years from now, your new junior colleague is assigned a task to add an “In stock” checkbox. They add a new field to the API:

#[derive(Deserialize)]
struct ProductFilter {
    pub price_from: Option<usize>,
    pub price_to: Option<usize>,
    #[serde(default)]
    pub must_be_in_stock: bool,
}

The code compiles right away, but it doesn’t do anything. How do they navigate your enterprise codebase and make sure that this new field is handled where it needs to be handled? 1

The solution

Recognize this “all fields” pattern early, anticipate new fields in the future, and write the original implementation like so:

impl IntoCondition for ProductFilter {
    pub fn into_condition(self) -> Condition {
        // Destructure without `..` to cause a compile error when a new field is added.
        // It will remind us to use the new field here.
        let ProductFilter { price_from, price_to } = self;

        let mut cond = Condition::all();
        if let Some(price_from) = price_from {
            cond.add(product::Column::Price.gte(price_from))
        }
        if let Some(price_to) = price_to {
            cond.add(product::Column::Price.lte(price_to))
        }
        cond
    }
}

The comment isn’t just for the sake of the blog. I actually write an explanatory comment when I do this. Here’s a real-world example from an open-source project.

Another cool benefit of this technique is that each field is bound to its own local variable and the compiler warns you when they are unused. This can easily happen after a refactoring or a sloppy copy-paste.

Bonus: other solutions

If we take the original question seriously and ponder it, a few other solutions come to mind. They have some flaws, but all of them can still be useful, especially if you work in another language:

If the handling of each field is extremely repetitive and you don’t need unique logic for every field, you can write a proc macro that defines the function for you and pastes a snippet of code for every field. That autogenerated function will always handle every field. And a single macro can be reused for multiple structs (if your processing is that repetitive).

But many functions can’t be generalized like that. Our original example would have to somehow annotate the corresponding SQL operations anyway:

#[derive(Deserialize, MyIntoCondition)]
struct ProductFilter {
    #[opt_condition(column = product::Column::Price, op = ColumnTrait::gte)]
    pub price_from: Option<usize>,
    #[opt_condition(column = product::Column::Price, op = ColumnTrait::lte)]
    pub price_to: Option<usize>,
}

To me, this just screams overengineering. And proc macros are bad for compile times .

But this pattern has its place. thiserror is very nice. It achieves a similar result to our destructuring, enforcing an #[error](..) annotation on every field and raising a compile error otherwise.

Man, I love exhaustiveness checks.


“Rust Design Patterns” is a good resource. Leave a comment if you liked my trick and want me to contribute it!

Discuss


  1. Granted, in this example it’s obvious that a filter must do the filtering, that such method already exists somewhere, and that we must find it. But in more complex cases, multiple such methods could exist, and they could exist for types that you wouldn’t expect to have such methods. ↩︎

  2. I got burnt on this so many times that I often search as /Product.?Filter/i right away. ↩︎

  3. Ba-dum-tss! 🥁 ↩︎

Comments