$elementary.dev
[Courses][About]|
$elementary.dev

Text-first, project-driven courses for developers who want to level up by building real things.

CoursesAbout
Stay Updated

Get notified about new courses and articles.

// No spam, unsubscribe anytime

© 2026 elementary.dev
PrivacyTerms
// Course

Elementary Rust

8/11
  • 01Hello, Rust
  • 02Variables and Types
  • 03Functions and Control Flow
  • 04Why Memory Management Matters
  • 05Ownership: Rust's Solution
  • 06Borrowing and References
  • 07Slices
  • 08Structs
  • 09Enums and Pattern Matching
  • 10Organizing Rust Projects
  • 11Collections
courses/elementary-rust/08-structs

Structs

Define custom types with named fields and attach behavior with impl blocks.

~25 min|#Lesson 8/11

Structs

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.

Why Structs Over Tuples?

// 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.

// Info-Naming convention

Struct names use PascalCase in Rust. Fields use snake_case. The compiler will warn you if you get this wrong.

Creating Instances

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.

// Warning-Mutability is all-or-nothing

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");

Field Init Shorthand

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.

Struct Update Syntax

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.

// Danger-Moves happen with update syntax

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.

Tuple Structs

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.

Unit-Like Structs

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.

Separating Data from Behavior

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.

// Tip-Multiple impl blocks

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 and self

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 }
    }
}
// Challenge

Method or Associated Function?

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.

SignatureAccess LevelWhat Happens
&selfRead-onlyBorrows immutably, instance still usable
&mut selfMutableBorrows mutably, exclusive access
selfOwnershipConsumes 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.

Associated Functions

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.

// Info-Convention: new for constructors

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.

Associated Constants

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.

Putting It All Together

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.

// Challenge

Rectangle Method Access

What method signature gives mutable access to self, allowing you to modify the struct's fields?

$

Summary

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:

  • Named fields make code self-documenting compared to tuples
  • Field init shorthand reduces boilerplate when variable names match field names
  • Update syntax (..other) creates variations but moves non-Copy fields
  • Tuple structs give you type safety without field names
  • impl blocks attach methods and associated functions to types
  • Method signatures control access: &self reads, &mut self modifies, self consumes
  • Associated functions (no self) are called with :: and often serve as constructors

Coming 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.

<-[prev]Slices[next]->Enums and Pattern Matching
Progress: 0%
// On this page
  • Why Structs Over Tuples?
  • Creating Instances
  • Field Init Shorthand
  • Struct Update Syntax
  • Tuple Structs
  • Unit-Like Structs
  • Separating Data from Behavior
  • Methods and self
  • Associated Functions
  • Associated Constants
  • Putting It All Together
  • Summary