Define custom types with named fields and attach behavior with impl blocks.
Rust structs let you group related data together under a single type. If you have used classes in other languages, structs will feel familiar but with an important twist.
// Tuple approach - what do these numbers mean?
let user_data = (true, "someone", "someone@example.com", 1);
// Struct approach - crystal clear
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}
Tuples work fine for simple cases, but structs give you three advantages. Named fields make your code self-documenting. You do not depend on field order (was active first or last?). And you can attach methods directly to the type.
Struct names use PascalCase in Rust. Fields use snake_case. The compiler will warn you if you get this wrong.
let user1 = User {
active: true,
username: String::from("someone"),
email: String::from("someone@example.com"),
sign_in_count: 1,
};
// Access fields with dot notation
println!("Email: {}", user1.email);
You can list fields in any order when creating an instance. Access them with dot notation just like you would expect.
You cannot mark individual fields as mutable. If you need to modify any field, the entire instance must be declared with mut. This keeps Rust's borrow checking simple and predictable.
let mut user1 = User {
active: true,
username: String::from("someone"),
email: String::from("someone@example.com"),
sign_in_count: 1,
};
user1.email = String::from("newemail@example.com");
fn build_user(email: String, username: String) -> User {
User {
active: true,
username, // shorthand: variable name matches field name
email, // no need to write email: email
sign_in_count: 1,
}
}
When your variable name matches the field name, you can skip the redundant repetition. This keeps builder functions clean and readable.
let user2 = User {
email: String::from("another@example.com"),
..user1 // copy remaining fields from user1
};
The ..user1 syntax fills in any fields you did not specify. This is perfect for creating variations of existing instances without repeating every field.
The update syntax moves any non-Copy fields it uses. After this code runs, user1.email is still valid (you provided a new value). But user1.username is invalid because it was moved. The whole user1 variable becomes partially moved and cannot be used as a complete struct anymore.
let user2 = User {
email: String::from("another@example.com"),
..user1
};
println!("{}", user1.active); // OK: bool implements Copy
println!("{}", user1.email); // OK: you provided a new value
// println!("{}", user1.username); // ERROR: was moved
// But you can't use the whole struct anymore:
// some_function(user1); // ERROR: partially moved
// println!("{:?}", user1); // ERROR: can't use whole struct
The original "whole" object becomes unusable while unmoved individual fields stay individually usable. If you need to keep both structs usable, call .clone() explicitly on the source.
Sometimes field names are redundant. A Color is obviously three numbers for red, green, and blue. Tuple structs give you a named type without named fields.
struct Color(i32, i32, i32);
struct Point(i32, i32, i32);
let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);
// These are different types even though they have the same fields!
// let c: Color = origin; // ERROR: mismatched types
Access fields by index: black.0, black.1, black.2. You can also destructure them like regular tuples: let Color(r, g, b) = black;.
The main benefit is type safety. You cannot accidentally pass a Point where a Color is expected, even though they have identical internal structure. The compiler treats them as completely different types.
struct AlwaysEqual;
let subject = AlwaysEqual;
Structs with no fields at all exist too. They take up zero bytes of memory. These are useful when you need a type to implement a trait but have no data to store. A common example is marker types that signal something to the compiler without carrying runtime data. We will see practical examples when we cover traits.
In Rust, each piece is separate. The struct defines the shape of your data. The impl block defines what you can do with that data. If you use traits, that's the behavior contract. Data definition, behavior contract, and behavior implementation are three distinct things.
This differs from languages where a class bundles data and methods together in one definition.
// Data definition
struct Rectangle {
width: u32,
height: u32,
}
// Behavior defined separately
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
}
The impl block (short for "implementation") attaches methods to a type. This separation has real benefits. You can add methods to types across multiple impl blocks, even in different files. The data layout is always clear at a glance without scrolling through method implementations. And anyone reading your code immediately understands what data the type holds versus what operations it supports.
This design also enables Rust's trait system, where you can implement external interfaces for your types in separate impl blocks. Coming from object-oriented languages, this separation might feel unusual at first. Give it time. Most Rustaceans come to appreciate how cleanly it organizes code.
You can split methods across multiple impl blocks for the same type. This is useful for organizing related methods or when implementing traits, which we will cover soon.
Methods receive self as their first parameter. This is how Rust knows the function operates on an instance of the type. How you write self determines what access the method has and whether the caller can continue using the instance afterward.
impl Rectangle {
// Immutable borrow - can read but not modify
fn area(&self) -> u32 {
self.width * self.height
}
// Mutable borrow - can modify fields
fn set_width(&mut self, width: u32) {
self.width = width;
}
// Takes ownership - consumes the instance
fn into_square(self) -> Rectangle {
let side = self.width.max(self.height);
Rectangle { width: side, height: side }
}
}
When you write String::from("hello"), does the from function use self?
The choice of self type follows the same borrowing rules you learned earlier.
| Signature | Access Level | What Happens |
|---|---|---|
&self | Read-only | Borrows immutably, instance still usable |
&mut self | Mutable | Borrows mutably, exclusive access |
self | Ownership | Consumes instance, caller cannot use it after |
Most methods use &self because they only need to read data. Use &mut self when you need to modify the struct. Reserve self (taking ownership) for transformations where the original should not exist anymore.
Not every function in an impl block needs self. Functions without self are called associated functions rather than methods.
impl Rectangle {
fn new(width: u32, height: u32) -> Rectangle {
Rectangle { width, height }
}
fn square(size: u32) -> Rectangle {
Rectangle { width: size, height: size }
}
}
// Called with :: not dot notation
let rect = Rectangle::new(30, 50);
let sq = Rectangle::square(20);
Associated functions are typically constructors or utility functions. The :: syntax tells you that you are calling a function associated with the type, not a method on an instance.
Rust does not have a built-in constructor keyword. By convention, the primary constructor is named new. Secondary constructors get descriptive names like square, from_points, or with_capacity.
You can also define constants inside impl blocks:
impl Point {
const ORIGIN: Point = Point { x: 0.0, y: 0.0 };
}
// Access with ::
let origin = Point::ORIGIN;
Associated constants belong to the type, not to instances. Access them with Type::CONSTANT syntax.
Let us build a complete example that demonstrates these concepts.
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
// Associated function (constructor)
fn new(width: u32, height: u32) -> Rectangle {
Rectangle { width, height }
}
// Method: read-only access
fn area(&self) -> u32 {
self.width * self.height
}
// Method: read-only access
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
// Method: mutable access
fn scale(&mut self, factor: u32) {
self.width *= factor;
self.height *= factor;
}
}
fn main() {
let mut rect1 = Rectangle::new(30, 50);
let rect2 = Rectangle::new(10, 40);
println!("Area: {}", rect1.area());
println!("Can hold rect2: {}", rect1.can_hold(&rect2));
rect1.scale(2);
println!("Scaled area: {}", rect1.area());
}
Notice how the can_hold method takes &Rectangle as a parameter. We are borrowing other rather than taking ownership, so the caller can continue using both rectangles.
What method signature gives mutable access to self, allowing you to modify the struct's fields?
Structs are Rust's way of creating custom types with named fields. The impl block pattern separates data definition from behavior, giving you flexibility in how you organize code.
Key takeaways:
..other) creates variations but moves non-Copy fields&self reads, &mut self modifies, self consumesself) are called with :: and often serve as constructorsComing up next, we will explore enums and pattern matching. Rust's enum system is far more powerful than enums in most languages, letting you attach different data to each variant.