Destructure as a Reminder
Table of Contents
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:
- Your colleague could ask you.
(Interrupting your work, and let’s hope that you know the right person, that they are still around, that they remember how this thing works, and that nothing has substantially changed since) - You could anticipate this, document the
ProductFilter
type, and explain its usage in a comment.
(And then that comment would get out-of-date) - Nowadays, your colleague could ask an LLM instead.
(With a chance to get back an imaginary method name) - Your colleague could use Git history to find the commit that added one of the
other fields.
(Let’s hope that nothing has substantially changed since, and that no other similar methods have been added) - Your colleague could use their IDE to find references to this type or its
other fields.
(Let’s hope that there aren’t too many references and that there’s no macro/reflection/tooling trickery that would mess with the language server 😩) - Your colleague could use old-school text search.
(Let’s hope that all relevant code is in a single repo, and there aren’t too many irrelevant results or non-matching camelCase/kebab-case/whatever variations of the identifier2) - Instead of destructuring, you could write a proc macro. Let’s expand3 on this a little.
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.
Related reading
“Rust Design Patterns” is a good resource. Leave a comment if you liked my trick and want me to contribute it!
Discuss
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. ↩︎
I got burnt on this so many times that I often search as
/Product.?Filter/i
right away. ↩︎Ba-dum-tss! 🥁 ↩︎
Comments