When data needs behavior
You are building a command parser for a CLI tool. The input stream produces tokens: SetKey, DeleteKey, ListKeys. You have an enum representing these commands. Now you need to actually execute them. In Python, you might write a giant function that checks if command.type == 'SetKey' and handles the logic. In JavaScript, you might attach a method to an object or use a strategy pattern with a dictionary. Both approaches scatter the logic away from the data definition. Adding a new command means hunting through the codebase to find every place that pattern-matches on the command type.
Rust lets you attach methods directly to the enum. The logic lives where the data lives. You define an impl block on the enum, write a method that pattern matches on self, and call it with dot syntax. The compiler guarantees you handle every variant. Adding a new variant breaks the build until you update the method. This keeps your code cohesive and your updates safe.
The impl block is just a function wrapper
An enum in Rust is a type. Just like a struct, you can define an inherent impl block for it. This block contains methods that take the enum as their first argument. The self parameter represents the instance of the enum. When you write cmd.execute(), Rust translates that to Command::execute(&cmd) behind the scenes. The dot syntax is sugar. The underlying mechanism is a function that receives the enum as input.
Think of the enum as a sealed box that can hold different shapes. The impl block is a set of instructions stamped on the outside of the box. No matter which shape is inside, you can follow the instructions to inspect or transform the contents. The method uses match self to open the box, see which variant is active, and act accordingly.
enum Shape {
Circle(f64),
Square(f64),
}
impl Shape {
/// Calculate the area based on the active variant.
fn area(&self) -> f64 {
// self is a reference to the Shape enum.
// We match on self to destructure the variant.
match self {
Shape::Circle(r) => {
// r is a reference to the f64 inside Circle.
// We dereference it implicitly for the math.
std::f64::consts::PI * r * r
}
Shape::Square(s) => s * s,
}
}
}
fn main() {
let c = Shape::Circle(5.0);
// Dot syntax calls the method, passing &c as self.
println!("Area: {}", c.area());
}
The method signature fn area(&self) means the method borrows the enum. It reads the data but does not take ownership. This is the default for query methods. You can also write fn area(self) to consume the enum, or fn area(&mut self) to mutate it. The choice depends on whether you need to move data out of the enum or modify its state.
Borrowing rules still apply
Methods on enums obey the same borrowing rules as any other Rust code. If your method takes &self, you cannot move data out of the enum. You can only borrow parts of it or clone them. This prevents you from stealing the contents while the original enum still exists.
Consider an enum that holds a String. If you try to return the String by value from a method that takes &self, the compiler rejects the code. You are trying to move owned data out of a borrowed reference. The compiler emits E0507 (cannot move out of borrowed content). You must either clone the data or return a reference to it.
enum Message {
Text(String),
Empty,
}
impl Message {
/// This compiles: we clone the string to return ownership.
fn get_text_clone(&self) -> String {
match self {
Message::Text(t) => t.clone(),
Message::Empty => String::new(),
}
}
/// This compiles: we return a reference to the string.
fn get_text_ref(&self) -> &str {
match self {
Message::Text(t) => t.as_str(),
Message::Empty => "",
}
}
/// This fails with E0507: cannot move out of borrowed content.
// fn get_text_move(&self) -> String {
// match self {
// Message::Text(t) => t, // ERROR: t is &String, cannot return String
// Message::Empty => String::new(),
// }
// }
}
Cloning is safe but costs allocation. Returning a reference is cheap but ties the lifetime of the result to the lifetime of the enum. Choose based on performance needs and API design. If the caller needs to own the data, clone. If the caller just needs to read it, return a reference.
The compiler forces you to be complete
One of the strongest benefits of methods on enums is exhaustiveness checking. When you pattern match on self inside a method, the compiler verifies that you handle every variant. If you add a new variant to the enum but forget to update the method, the build fails with E0004 (non-exhaustive patterns).
This is a superpower for maintenance. In languages without this guarantee, adding a new case to an enum can silently break logic elsewhere. You might add a Command::Restart variant and forget to handle it in the execution logic. The program runs, hits the new variant, and crashes or does nothing. In Rust, the compiler points you to the exact line where the match is incomplete. You fix it immediately.
enum Status {
Ok,
Error(String),
Pending,
}
impl Status {
/// Convert the status to a user-facing message.
fn message(&self) -> String {
match self {
Status::Ok => "Success".to_string(),
Status::Error(e) => format!("Error: {}", e),
// If you add Status::Pending later, this match becomes non-exhaustive.
// The compiler will emit E0004 until you add an arm for Pending.
}
}
}
Treat the match arm as a contract. The enum defines the possible states, and the method defines the behavior for each state. The compiler enforces that the contract is complete. Add the missing arm.
Real-world pattern: Commands and execution
In production code, enums often represent commands, events, or configuration options. A common pattern is to define an execute method that performs the action and returns a result. You might also add helper methods like is_write to classify the command without executing it.
enum Command {
SetKey { key: String, value: String },
DeleteKey(String),
ListKeys,
}
impl Command {
/// Execute the command and return a status message.
fn execute(&self) -> String {
match self {
Command::SetKey { key, value } => {
format!("Set {} to {}", key, value)
}
Command::DeleteKey(key) => {
format!("Deleted {}", key)
}
Command::ListKeys => {
"Listing keys...".to_string()
}
}
}
/// Check if the command modifies state.
fn is_write(&self) -> bool {
// Use matches! macro for simple boolean checks.
// This is cleaner than a full match expression.
matches!(self, Command::SetKey { .. } | Command::DeleteKey(_))
}
}
fn main() {
let cmd = Command::SetKey {
key: "theme".to_string(),
value: "dark".to_string(),
};
if cmd.is_write() {
println!("Executing write: {}", cmd.execute());
}
}
The matches! macro is a convention for simple checks. It expands to a match expression that returns true for the specified patterns and false otherwise. It keeps the code concise. Use matches! when you only need a boolean result. Use a full match when you need to extract data or return different values.
Convention: Naming and structure
Methods on enums follow the same naming conventions as methods on structs. Use verbs for actions: execute, render, serialize. Use noun-verbs for queries: area, description, to_string. Keep the method focused. If a method grows too large, extract helper functions or split the enum into smaller types.
Document the method with a doc comment. Explain what it does and any invariants it relies on. If the method returns a reference, mention the lifetime constraints. If it clones data, note the performance implication.
impl Command {
/// Serialize the command to a JSON string.
///
/// Returns an empty string if serialization fails.
fn to_json(&self) -> String {
// Implementation details...
String::new()
}
}
The community expects #[derive(Debug)] on enums for logging and debugging. Add it unless you have a specific reason not to. It provides a default Debug implementation that prints the variant and its data.
Choosing the right tool
You have options for attaching behavior to enums. Pick the right one based on the scope of the behavior.
Use an inherent impl block when the logic is specific to the enum and does not need to be shared across unrelated types. This is the default choice for methods like execute, area, or is_write.
Use a trait implementation when you want to group behavior with other types. Implement Display for user-facing strings, PartialEq for comparison, or a custom trait like Serializable for data formats. Traits allow polymorphism and shared interfaces.
Use a free function when the operation does not conceptually belong to the enum. Construction functions like Command::from_string often live as inherent associated functions (methods without self). Utility functions that transform enums without being part of their core behavior can be free functions in a module.
Use a macro when you have repetitive boilerplate across many similar enums. This is rare for simple method attachment. Macros add complexity. Prefer inherent impl blocks unless you are generating code for dozens of enums with identical structure.
Keep the logic with the data. The enum knows how to behave. Don't scatter the implementation across unrelated modules.