Data model
Rust has three primary ways to define data:
struct— a named collection of fields. Like a class without methods built in.enum— a closed set of variants, each variant possibly carrying data. Sum type.trait— shared behavior. Like an interface, but with more.
Most Rust programs are mostly structs and enums, with traits used sparingly to express the few real polymorphic interfaces.
Struct
pub struct User {
pub id: u64,
pub email: String,
pub created_at: chrono::DateTime<Utc>,
}The default. Use it for any "thing with named parts."
Three variants exist:
struct Classic { x: i32, y: i32 } // named fields
struct Tuple(i32, i32); // positional fields
struct Unit; // no fields, just a nameUse named fields by default. Tuple structs are useful for newtypes (struct UserId(u64)). Unit structs are markers.
Enum
The most undervalued feature in mainstream programming languages. Rust enums are closed tagged unions: a fixed set of variants, each variant possibly carrying its own data.
pub enum Event {
UserCreated { id: u64, email: String },
UserDeleted(u64),
ConfigChanged,
}Three variant styles in the same enum:
- Struct variant (
UserCreated { ... }). - Tuple variant (
UserDeleted(u64)). - Unit variant (
ConfigChanged).
The compiler enforces exhaustive match:
fn handle(event: &Event) {
match event {
Event::UserCreated { id, email } => log_create(*id, email),
Event::UserDeleted(id) => log_delete(*id),
Event::ConfigChanged => reload_config(),
}
}Add a variant to Event, this function fails to compile until you handle it. That is the safety guarantee.
Use enums whenever you have a closed set: states in a state machine, kinds of a thing, AST node types, error variants. Not as inheritance hierarchies, not as "discriminated unions with downcasting." Just: this is one of these variants, end of story.
Trait
Shared behavior. Like an interface, but more expressive:
pub trait Display {
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), fmt::Error>;
}
impl Display for User {
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), fmt::Error> {
write!(f, "user:{}", self.id)
}
}Traits can have default method implementations, associated types, generic methods, and supertrait bounds. Most uses you will see fall into a few categories:
| Use case | Example traits |
|---|---|
| Formatting / display | Debug, Display |
| Conversion | From, Into, TryFrom, TryInto, AsRef, AsMut |
| Comparison | PartialEq, Eq, Ord, Hash |
| Iteration | Iterator, IntoIterator, FromIterator |
| Error | Error (from std::error) |
| Concurrency markers | Send, Sync |
| Async | Future, custom async traits with async_trait |
| Domain abstractions | Your own. Use sparingly. |
When to use which
The decision tree:
- One thing with parts? Struct.
- One of a fixed set of things, maybe with different data per kind? Enum.
- Multiple things that share behavior, and you want code to be generic over them? Trait.
- Multiple things that share data? Composition (a struct that holds them) or an enum with a struct variant per case. Not inheritance, because Rust does not have inheritance.
The single most common mistake from agent-written Rust: reaching for a trait when an enum would do. If the variants are known and closed, the enum is simpler, faster, more local, and more refactorable.
Sail picks the same way
From the Sail tour on trait design: CatalogProvider is a trait because the implementations live in different crates and can be added by users. QueryNode is an enum because the variants are fixed and the engine knows them all.
The rule of thumb: traits at extension points, enums everywhere else.
Newtype: a one-field tuple struct
A common idiom:
pub struct UserId(pub u64);
pub struct OrderId(pub u64);Now you can't accidentally pass a UserId where an OrderId is expected. The compiler catches it. Cheap, effective, idiomatic.