S17: Struct with many Option fields that are really states
What it looks like
Section titled “What it looks like”A subscription is in a trial, active, or cancelled. Each state has its own data, and all of it is in Option fields:
pub struct Subscription { pub customer: String, pub trial_ends_on: Option<String>, pub paid_until: Option<String>, pub card_last4: Option<String>, pub cancelled_on: Option<String>, pub cancel_reason: Option<String>,}
pub fn describe(s: &Subscription) -> String { if let Some(day) = &s.cancelled_on { format!("cancelled on {day}") } else if let Some(day) = &s.paid_until { format!( "paid until {day}, card {}", s.card_last4.as_deref().unwrap_or("????") ) } else if let Some(day) = &s.trial_ends_on { format!("trial until {day}") } else { "unknown".to_string() }}Why AI agents produce it
Section titled “Why AI agents produce it”- It is the shape of a database row or a JPA entity. A table has one column for each value, and columns that only some rows use are nullable. The agent translates each nullable column to an
Option. - It is the safe translation of
null. The agent knows that Rust has nonull, so each nullable Java field becomesOption<T>. That is correct for one field. For a group of fields that depend on each other, it misses the state. - New states add new fields. “Add a cancel reason” becomes one more
Option.
Why it is a problem
Section titled “Why it is a problem”- Impossible states compile. Five
Optionfields have 32 combinations of set and not set. “Cancelled, but no cancel date”, “paid, but no card”, and “trial and cancelled” are all valid values. The testworse_accepts_states_that_cannot_existbuilds one. - The state is hidden in the order of checks.
describetestscancelled_onfirst, thenpaid_until, thentrial_ends_on. That order is now a business rule, and it is written nowhere else. - Callers must handle
Nonewhere it cannot happen. An active subscription always has a card, but the code needsunwrap_or("????"), or anunwrap()that a reviewer must check. - There is an “unknown” case. The last
elseexists only because the type allows a subscription in no state at all.
The better version
Section titled “The better version”WorseFive
Optionfields, anif letchain whose order is a rule, and an "unknown" case.pub struct Subscription {pub customer: String,pub trial_ends_on: Option<String>,pub paid_until: Option<String>,pub card_last4: Option<String>,pub cancelled_on: Option<String>,pub cancel_reason: Option<String>,}pub fn describe(s: &Subscription) -> String {if let Some(day) = &s.cancelled_on {format!("cancelled on {day}")} else if let Some(day) = &s.paid_until {format!("paid until {day}, card {}",s.card_last4.as_deref().unwrap_or("????"))} else if let Some(day) = &s.trial_ends_on {format!("trial until {day}")} else {"unknown".to_string()}}BetterA
Stateenum. Each variant has exactly the fields that it needs.describeis one exhaustivematch.pub struct Subscription {pub customer: String,pub state: State,}pub enum State {Trial {ends_on: String,},Active {paid_until: String,card_last4: String,},Cancelled {on: String,reason: Option<String>,}, // a reason is really optional}pub fn describe(s: &Subscription) -> String {match &s.state {State::Trial { ends_on } => format!("trial until {ends_on}"),State::Active {paid_until,card_last4,} => format!("paid until {paid_until}, card {card_last4}"),State::Cancelled { on, .. } => format!("cancelled on {on}"),}}
In the “better” version, an active subscription always has paid_until and card_last4, as plain String values. There is no “unknown”. The fields that all states share (customer) stay in the struct. One Option remains, reason, because a cancellation really can have no reason.
When it is acceptable
Section titled “When it is acceptable”- Fields that are optional on their own: a nickname, a second address line, a phone number. Each can be present or not, in any combination, and no state decides it.
- At the boundary, the type that a database row or a JSON document is read into can match its shape. Convert it to the enum once, with
TryFrom, and reject rows that are in no valid state.
How to detect it
Section titled “How to detect it”- No Clippy lint reports this pattern.
- Look for three or more
Optionfields with names that belong to states:*_at,*_on,*_until,cancel_*,refund_*. - Look for
if let ... else if let ...chains over different fields, and forunwrap()on a field “that is always set when…”.
What to tell your agent
Section titled “What to tell your agent”Subscription has Option fields that depend on its state. Replace them withan enum State { Trial { .. }, Active { .. }, Cancelled { .. } }, where eachvariant holds only the fields that state needs, without Option where thevalue is always present. Keep shared fields in the struct. Replace theif-let chains with one exhaustive match. Add a TryFrom conversion from thedatabase row type that rejects rows in no valid state. Run the tests.Related: S15: bool flags or string constants instead of an enum, Enums and pattern matching, and Option, not null.