Is there a way to detect at compile time that I'm not filling needed fields of these structs?

I'm using structs like the below in my Rust app:

pub struct Invoice {
    pub id: Uuid,
    pub amount: i64,
    pub rows: Vec<InvoiceRow>,
    pub company_id: Uuid,
    pub company: Option<Box<Company>>,
    pub customer_id: Uuid,
    pub customer: Option<Box<Customer>>,
    pub payments: Vec<Payment>,
    // and many many others
}

pub struct Payment {
    pub id: Uuid,
    pub amount: i64,
    pub invoice_id: Uuid,
    pub payment_method_id: Uuid,
    pub payment_method: Option<Box<PaymentMethod>>,
    // and many many others
}

pub struct PaymentMethod {
    pub id: Uuid,
    pub name: String,
    pub mode: String,
    // and many many others
}

Than I have methods like this:

fn payments(invoice: &Invoice) -> String {
    let payments = invoice
        .payments
        .iter()
        .fold(String::new(), |mut output, payment| {
            let _ = write!(
                output,
                "mode: {mode} - amount: {amount}\n",
                mode = payment.payment_method.as_ref().expect("payment_method").mode(),
                amount = payment.amount(),
            );
            output
        });

    payments
}

In this case Invoice is being created from a DB call which can query payments field (Vec<Payment>) based on a code like this:

pub struct InvoiceFieldsToQuery {
    pub rows: Option<Box<InvoiceRowFieldsToQuery>>,
    pub company: Option<Box<CompanyFieldsToQuery>>,
    pub customer: Option<Box<CustomerFieldsToQuery>>,
    pub payments: Option<Box<PaymentFieldsToQuery>>,
    // and many many others
}

let fields_to_query = InvoiceFieldsToQuery::default()
    .query_company(Some(Box::new(
        CompanyFieldsToQuery::default().query_address(),
    )))
    .query_customer(Some(Box::new(
        CustomerFieldsToQuery::default().query_address(),
    )))
    .query_payments(Some(Box::new(
        PaymentFieldsToQuery::default())
    ));
    // and many many others

As you can see I'm using code like .expect("payment_method") that I don't want to use.

Is there a way to avoid the usage of .expect() using something to detect and block at compile time a struct without a field?

Or - better - a way to detect at compile time that I'll need to use payments (or rows etc.) and its payment_method so I must query it with .query_payments(Some(Box::new(PaymentFieldsToQuery::default())))?

I'm ready to change everything in my code to achieve this compile time check!

The simple answer is: “You need a run-time check because the payment_method field is an Option. To avoid that run-time check, make it non-optional. Now its absence can be detected at compile time.”

Of course, presumably you have some reason that it's Option. So, you will have to design your structures in a different way. In order to know what is suitable, we will need to know what that reason is. Under what conditions would it be okay for the payment_method of a Payment to be None? With the answer to that question, we can then think about what structures could be used instead.

5 Likes

And sometimes this involves having multiple types, like a builder type which can be fallibly transformed into a type without Options. (You consolidate all your checks into one site: the conversion from builder type to bulit type.)

5 Likes

It can get quite messy if you have a lot of optional fields, but you can do something like this:

pub struct Invoice<PM> {
    pub id: Uuid,
    pub amount: i64,
    pub rows: Vec<InvoiceRow>,
    pub company: Option<Box<Company>>,
    pub customer: Option<Box<Customer>>,
    pub payments: Vec<Payment<PM>>,
    // and many many others
}

pub struct Payment<PM> {
    pub id: Uuid,
    pub amount: i64,
    pub invoice_id: Uuid,
    pub payment_method_id: Uuid,
    pub payment_method: PaymentMethod,
    // and many many others
}

pub struct PaymentMethod {
    pub id: Uuid,
    pub name: String,
    pub mode: String,
    // and many many others
}

fn payments(invoice: &Invoice<PaymentMethod>) -> String {
    let payments = invoice
        .payments
        .iter()
        .fold(String::new(), |mut output, payment| {
            let _ = write!(
                output,
                "mode: {mode} - amount: {amount}\n",
                mode = payment.payment_method.mode(),
                amount = payment.amount(),
            );
            output
        });

    payments
}

impl Payment<()> {
     fn with_method(self, query_results: &HashMap<Uuid, PaymentMethod>)->Result<Payment<PaymentMethod>, Self> {
        let Some(payment_method) = query_results.get(self.payment_method_id) else { return Err(self); };
        Ok(Payment {
            id: self.id,
            amount: self.amount,
            // …
            payment_method: payment_method.clone(),
            // …
        })
    }
}

Of course, presumably you have some reason that it's Option.

Yeah, I need it to be Option because I'm querying it only when I need it. And I don't always need it.

pub struct Invoice {
    pub id: Uuid,
    pub amount: i64,
    pub rows: Vec<InvoiceRow>,
    pub company_id: Uuid,
    pub company: Option<Box<Company>>,
    pub customer_id: Uuid,
    pub customer: Option<Box<Customer>>,
    pub payments: Vec<Payment>,
    // and many many others
}

For example for Invoice sometimes I need rows, payments and customer, sometimes just customer.

This is the issue.

I would like to detect at compile time when I need payments that I'm using an Invoice struct with payments field filled.

Under what conditions would it be okay for the payment_method of a Payment to be None?

As you can see payment_method_id: Uuid is not Option. This because a Payment is invalid without a PaymentMethod.

But since sometimes I don't need payment_method for the Payment struct I'm using the payment_method field is Option<Box<T>>.

pub struct Payment {
    pub id: Uuid,
    pub amount: i64,
    pub invoice_id: Uuid,
    pub payment_method_id: Uuid,
    pub payment_method: Option<Box<PaymentMethod>>,
    // and many many others
}

Is it clearer now?

Sorry for not explaining this better earlier.

Can you write a small example to better understand?

1 Like

Thanks but this is the case indeed. A lot of fields!

Here's an ownership based example.

There are many variations such as borrowing-based, trait based, generic parameter based...

The general idea is that you partition of the code where having the values is a prerequisite, hopefully limit the transition points in and out of the partition to keep things clean, and check that the values are present at the transition points (by changing types).

It's not well suited for every problem. If you have some combinatorial number of valid states, you might be better off with the dynamic checks, for example.

2 Likes

What do you mean by this?

The PaymentWithMethod is not a solution because I need to handle multiple fields. Maybe a Trait for each field, such as HasPayments and HasCustomer and so on?

Let's say you had ten optional fields and there's no clean separation of concerns as to when any given set of those fields is required. Maybe you don't know until runtime for some reason, or maybe your business needs come up with new use cases that require some new set of fields at a high rate. Maybe some requirements are more complicated too.[1]

It wouldn't be practical to have 210 data types and all the possible conversions between them to concretely specify exactly which set of fields are definitely present at compile time.

Even if the use cases "only" require dozens of scenarios, it might not be worth it.


  1. "I need a PaymentMethod or a ResponsibleParty, but not necessarily both..." ↩︎

2 Likes

So you're suggesting I'm fine with expect or let Some() everywhere, right?

ChatGPT 4 gave me an answer that maybe can fix it. But there are errors. What do you think?

trait WithRows {}
trait WithPayments {}
trait WithCompany {}
trait WithCustomer {}
trait WithPaymentMethod {
    fn mode(&self) -> &str;
}

struct NoRows;
struct Rows(Vec<InvoiceRow>);

struct NoPayments;
struct Payments(Vec<Payment<PaymentMethod>>);

struct NoCompany;
struct Company(Box<Company>);

struct NoCustomer;
struct Customer(Box<Customer>);

impl WithRows for Rows {}
impl WithPayments for Payments {}
impl WithCompany for Company {}
impl WithCustomer for Customer {}

pub struct InvoiceRow;

pub struct Payment<PM = NoPaymentMethod> {
    pub id: String,
    pub amount: i64,
    pub invoice_id: String,
    pub payment_method_id: String,
    pub payment_method: PM,
}

pub struct NoPaymentMethod;
pub struct PaymentMethod {
    pub id: String,
    pub name: String,
    pub mode: String,
}

impl WithPaymentMethod for PaymentMethod {
    fn mode(&self) -> &str {
        &self.mode
    }
}

pub struct Invoice<R = NoRows, C = NoCompany, CU = NoCustomer, P = NoPayments> {
    pub id: String,
    pub amount: i64,
    pub rows: R,
    pub company: C,
    pub customer: CU,
    pub payments: P,
}

impl<PM> Payment<PM>
where
    PM: WithPaymentMethod,
{
    pub fn payment_details(&self) -> String {
        format!(
            "mode: {} - amount: {}",
            self.payment_method.mode(), self.amount
        )
    }
}

impl<R, C, CU, P> Invoice<R, C, CU, P>
where
    P: WithPayments,
{
    pub fn payments(&self) -> String {
        self.payments
            .0
            .iter()
            .map(|payment| payment.payment_details())
            .collect::<Vec<_>>()
            .join("\n")
    }
}

impl<R, C, CU, P> Invoice<R, C, CU, P>
where
    R: WithRows,
{
    pub fn list_rows(&self) -> String {
        // Assuming InvoiceRow has a display implementation
        self.rows
            .0
            .iter()
            .map(|row| format!("{:?}", row))
            .collect::<Vec<_>>()
            .join("\n")
    }
}

fn main() {
    let payment_method = PaymentMethod {
        id: String::new(),
        name: "Credit Card".into(),
        mode: "Online".into(),
    };
    let payment = Payment {
        id: String::new(),
        amount: 100,
        invoice_id: String::new(),
        payment_method_id: String::new(),
        payment_method,
    };
    let payments = Payments(vec![payment]);

    let rows = Rows(vec![InvoiceRow {}]);
    let company = Company(Box::new(Company));
    let customer = Customer(Box::new(Customer {}));

    let invoice = Invoice {
        id: String::new(),
        amount: 1000,
        rows,
        company,
        customer,
        payments,
    };

    println!("{}", invoice.payments());
    println!("{}", invoice.list_rows());
}

I'm saying it depends on your use case. Sometimes it's fine. I don't know your use case well enough to have a strong a opinion for that specifically.

Though they're not the compile-type check you asked for, sometimes you can improve ergonomics with other abstractions. Examples.

1 Like

Thanks.

IMO it mainly comes down to, "is being None here a programming error or something a user (or library consumer) may encounter"?

All what I'm trying to defend against with this are programmer mistakes.

User ones are already handled.

1 Like

It looks to me like you really have multiple, distinct kinds of queries. You should really model these using different types. It seems to me you are trying to have a false sense of "generalization", but by trying to shove every query to an Invoice-shaped hole, you are actually making your own life unnecessarily difficult. Just make all the required fields non-optional, and if two use cases demand different sets of fields, then make two different types to represent them.

3 Likes

Just make all the required fields non-optional, and if two use cases demand different sets of fields, then make two different types to represent them.

So you're proposing to use structs like these, right?:

pub struct InvoiceWithAllFields {
    pub id: Uuid,
    pub amount: i64,
    pub rows: Vec<InvoiceRow>,
    pub company_id: Uuid,
    pub company: Box<Company>,
    pub customer_id: Uuid,
    pub customer: Box<Customer>,
    pub payments: Vec<Payment>,
    // and many many others
}

pub struct InvoiceWithCustomer {
    pub id: Uuid,
    pub amount: i64,
    pub rows: Vec<InvoiceRow>,
    pub company_id: Uuid,
    pub company: Option<Box<Company>>,
    pub customer_id: Uuid,
    pub customer: Box<Customer>,
    pub payments: Vec<Payment>,
    // and many many others
}

Did I get it right?

Yes, except that the boxes seem to be redundant.

What do you mean?

There doesn't seem to be any need for boxing the Company and Customer-typed fields.