Structs, Enums and Traits
Structs, enums, pattern matching, Option and Result error handling with ?, and traits.
Structs
A struct groups related data together under one named type, similar in spirit to a class's fields but without inheritance:
struct User {
username: String,
email: String,
active: bool,
}
fn main() {
let user1 = User {
username: String::from("ali"),
email: String::from("ali@example.com"),
active: true,
};
println!("{} <{}>", user1.username, user1.email);
}
Methods are defined separately, in an impl (implementation) block:
impl User {
// an associated function (no `self`) — called like User::new(...), a common constructor pattern
fn new(username: &str, email: &str) -> User {
User {
username: username.to_string(),
email: email.to_string(),
active: true,
}
}
// a method — takes &self, borrows the instance
fn describe(&self) -> String {
format!("{} <{}>", self.username, self.email)
}
// a method that mutates the instance needs &mut self
fn deactivate(&mut self) {
self.active = false;
}
}
fn main() {
let mut user1 = User::new("ali", "ali@example.com");
println!("{}", user1.describe()); // ali <ali@example.com>
user1.deactivate();
println!("{}", user1.active); // false
}
Rust also has tuple structs (fields identified by position, useful for lightweight wrapper types) and unit structs (no fields at all, useful for marker types):
struct Point(f64, f64);
let origin = Point(0.0, 0.0);
println!("{} {}", origin.0, origin.1);
Enums
A Rust enum defines a type by listing all the values it could possibly be — and unlike enums in many other languages, each variant can carry its own data:
enum WebEvent {
PageLoad, // a variant with no data
Click { x: i64, y: i64 }, // a variant with named fields
KeyPress(char), // a variant with one unnamed field
}
Pattern matching with match
match compares a value against a set of patterns and runs the code for the first match — and critically, it's exhaustive: the compiler refuses to build your program if you forget a case.
fn describe(event: WebEvent) -> String {
match event {
WebEvent::PageLoad => String::from("page loaded"),
WebEvent::Click { x, y } => format!("clicked at ({x}, {y})"),
WebEvent::KeyPress(c) => format!("pressed '{c}'"),
}
}
fn main() {
println!("{}", describe(WebEvent::Click { x: 10, y: 20 })); // clicked at (10, 20)
}
match also works well with ranges and guards, and _ acts as a catch-all:
fn categorize(n: i32) -> &'static str {
match n {
0 => "zero",
1..=9 => "single digit",
n if n < 0 => "negative",
_ => "large",
}
}
Option<T> — no null pointers
Rust has no null. Instead, any value that might be absent is represented with the standard library's Option<T> enum:
enum Option<T> {
Some(T),
None,
}
fn find_user(id: u32) -> Option<String> {
if id == 1 {
Some(String::from("Ali"))
} else {
None
}
}
fn main() {
match find_user(1) {
Some(name) => println!("Found: {name}"),
None => println!("No user found"),
}
// common shortcuts for the "give me a default if it's None" case
let name = find_user(2).unwrap_or(String::from("Guest"));
println!("{name}"); // Guest
}
Because Option<T> and T are different types, the compiler forces you to explicitly handle the "might be absent" case before you can get at the value inside — there's no way to accidentally dereference a null pointer, because there are no null pointers.
Result<T, E> — no exceptions
Recoverable errors use Result<T, E>, another standard library enum:
enum Result<T, E> {
Ok(T),
Err(E),
}
fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err(String::from("division by zero"))
} else {
Ok(a / b)
}
}
fn main() {
match divide(10.0, 2.0) {
Ok(result) => println!("Result: {result}"),
Err(e) => println!("Error: {e}"),
}
}
The ? operator
Manually matching on every Result gets verbose fast, especially when chaining several fallible operations. The ? operator propagates an Err immediately, returning it from the enclosing function — and unwraps the Ok value otherwise:
use std::num::ParseIntError;
fn parse_and_double(input: &str) -> Result<i32, ParseIntError> {
let n: i32 = input.parse()?; // returns early with the Err if parsing fails
Ok(n * 2)
}
fn main() {
match parse_and_double("21") {
Ok(n) => println!("Doubled: {n}"), // Doubled: 42
Err(e) => println!("Parse error: {e}"),
}
match parse_and_double("not a number") {
Ok(n) => println!("Doubled: {n}"),
Err(e) => println!("Parse error: {e}"), // Parse error: invalid digit found in string
}
}
? can only be used in a function whose own return type is compatible (Result or Option), which keeps error propagation explicit and visible in every function signature — you can always tell which functions can fail just by reading their signature.
Traits — Rust's interfaces
A trait defines shared behavior a type can implement, similar to an interface in Java or Go:
trait Summary {
fn summarize(&self) -> String;
// traits can also provide a default implementation
fn preview(&self) -> String {
format!("Preview: {}", self.summarize())
}
}
struct Article {
title: String,
body: String,
}
impl Summary for Article {
fn summarize(&self) -> String {
format!("{}: {}...", self.title, &self.body[..20.min(self.body.len())])
}
}
fn main() {
let article = Article {
title: String::from("Rust 101"),
body: String::from("Rust is a systems programming language."),
};
println!("{}", article.summarize()); // uses the trait method
println!("{}", article.preview()); // uses the default method
}
Traits are also how Rust achieves generic, polymorphic code — a function can accept "anything that implements Summary" either via impl Trait (static dispatch, resolved and specialized at compile time) or dyn Trait (dynamic dispatch through a vtable, resolved at runtime, useful when the concrete type isn't known until runtime, e.g. a mixed collection):
fn print_summary(item: &impl Summary) { // static dispatch — monomorphized per concrete type
println!("{}", item.summarize());
}
fn print_summary_dyn(item: &dyn Summary) { // dynamic dispatch — one shared function, vtable lookup
println!("{}", item.summarize());
}
Common mistakes
- Forgetting
matchmust be exhaustive and being surprised the compiler rejects a missing case — this is intentional; add a_ => ...arm if you genuinely want to ignore the rest. - Calling
.unwrap()on anOption/Resultin production code paths — it panics onNone/Err, which is fine for prototypes and tests but rarely what you want in real request-handling code. Prefermatch,?, or combinators like.unwrap_or(). - Reaching for
dyn Traiteverywhere out of habit —impl Trait/ generics give you static dispatch (usually faster, since the compiler can inline and specialize) and should be the default unless you specifically need a heterogeneous collection or runtime polymorphism.
Interview questions
Q: Why does Rust use Option<T> instead of allowing null?
Because Option<T> and T are distinct types, the compiler forces every call site that might receive an absent value to explicitly handle both the Some and None cases before accessing the inner value — eliminating null-pointer/reference errors as a runtime failure mode entirely.
Q: How does Result<T, E>-based error handling compare to exceptions?
Errors are ordinary return values, visible directly in a function's signature (-> Result<T, E>), and the compiler forces the caller to acknowledge them (or explicitly propagate with ?). Exceptions can silently unwind through many stack frames with no signature-level indication that a function can fail — Result makes the failure path part of the type system instead.
Q: What's the difference between impl Trait and dyn Trait as a function parameter?
impl Trait is resolved at compile time — the compiler generates a specialized version of the function per concrete type (static dispatch/monomorphization), which is typically faster but increases binary size. dyn Trait is resolved at runtime via a vtable (dynamic dispatch) — one shared function body, slightly slower per call, but usable when you need a single collection holding several different concrete types behind the same trait.