In the previous tutorial, we learned borrowing and references. Now we learn how to create custom types with structs and attach behavior to them with methods.

If you have used classes in Kotlin, Java, or Python, structs will feel familiar. Rust does not have classes, but structs with impl blocks give you the same power — without inheritance.

Defining a Struct

A struct groups related data together:

struct User {
    name: String,
    email: String,
    age: u32,
    active: bool,
}

Each field has a name and a type. This is similar to a data class in Kotlin or a class in Python.

Creating Instances

You create a struct instance by providing values for every field:

fn main() {
    let user = User {
        name: String::from("Alex"),
        email: String::from("alex@example.com"),
        age: 28,
        active: true,
    };

    println!("Name: {}", user.name);
    println!("Age: {}", user.age);
}

Every field must have a value. Rust has no null or undefined.

Mutable Instances

To modify fields, the entire instance must be mutable. You cannot make individual fields mutable:

fn main() {
    let mut user = User {
        name: String::from("Alex"),
        email: String::from("alex@example.com"),
        age: 28,
        active: true,
    };

    user.age = 29;  // OK — entire struct is mut
    println!("Updated age: {}", user.age);
}

Field Init Shorthand

When variable names match field names, you can use the shorthand:

fn create_user(name: String, email: String) -> User {
    User {
        name,    // Same as name: name
        email,   // Same as email: email
        age: 0,
        active: true,
    }
}

Struct Update Syntax

Create a new struct from an existing one, changing only some fields:

fn main() {
    let user1 = User {
        name: String::from("Alex"),
        email: String::from("alex@example.com"),
        age: 28,
        active: true,
    };

    let user2 = User {
        email: String::from("sam@example.com"),
        name: String::from("Sam"),
        ..user1  // Copy remaining fields from user1
    };

    println!("User2 age: {}", user2.age);  // 28 (from user1)
}

Note: If the update uses fields that are String (or any non-Copy type), those fields move from the original. After the update, those fields in user1 are no longer valid. In the example above, user1.age and user1.active still work because they are Copy types.

Impl Blocks — Adding Methods

Methods are functions attached to a struct. You define them in an impl block:

struct Rectangle {
    width: f64,
    height: f64,
}

impl Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }

    fn perimeter(&self) -> f64 {
        2.0 * (self.width + self.height)
    }
}

fn main() {
    let rect = Rectangle { width: 10.0, height: 5.0 };
    println!("Area: {}", rect.area());
    println!("Perimeter: {}", rect.perimeter());
}

Method Parameters: &self, &mut self, self

The first parameter of a method determines how it accesses the struct:

ParameterMeaningUse When
&selfImmutable borrowReading data
&mut selfMutable borrowModifying data
selfTakes ownershipConsuming/transforming the struct
impl Rectangle {
    // Read only
    fn area(&self) -> f64 {
        self.width * self.height
    }

    // Modify the struct
    fn scale(&mut self, factor: f64) {
        self.width *= factor;
        self.height *= factor;
    }

    // Consume the struct and return something new
    fn into_square(self) -> Rectangle {
        let side = self.width.max(self.height);
        Rectangle { width: side, height: side }
    }
}

Most methods use &self. Use &mut self when you need to modify data. Use self (taking ownership) only when you want to transform the struct into something else.

Associated Functions (Constructors)

Functions in an impl block that do NOT take self are called associated functions. You call them with :: instead of .:

impl Rectangle {
    fn new(width: f64, height: f64) -> Rectangle {
        Rectangle { width, height }
    }

    fn square(size: f64) -> Rectangle {
        Rectangle { width: size, height: size }
    }
}

fn main() {
    let rect = Rectangle::new(10.0, 5.0);
    let sq = Rectangle::square(7.0);

    println!("Rectangle area: {}", rect.area());
    println!("Square area: {}", sq.area());
}

Rectangle::new() is the Rust convention for constructors. There is no special new keyword — it is just a naming convention.

Tuple Structs

Tuple structs have fields but no field names. They are useful for simple wrappers:

struct Color(u8, u8, u8);
struct Meters(f64);

fn main() {
    let red = Color(255, 0, 0);
    let distance = Meters(42.5);

    println!("Red value: {}", red.0);
    println!("Distance: {} meters", distance.0);
}

Even though Color and Meters both wrap numbers, they are different types. You cannot mix them by accident. This is called the newtype pattern.

Derive Macros

Rust can automatically implement common traits with #[derive]:

#[derive(Debug, Clone, PartialEq)]
struct Point {
    x: f64,
    y: f64,
}

fn main() {
    let p1 = Point { x: 1.0, y: 2.0 };

    // Debug — print with {:?}
    println!("Debug: {:?}", p1);  // Point { x: 1.0, y: 2.0 }

    // Clone — create an independent copy
    let p2 = p1.clone();

    // PartialEq — compare with ==
    println!("Equal: {}", p1 == p2);  // true
}

Common derive macros:

MacroWhat It Does
DebugEnables {:?} printing
CloneEnables .clone()
PartialEqEnables == and !=
EqFull equality (requires PartialEq)
HashEnables use in HashMap keys
DefaultProvides Type::default()
CopyEnables implicit copy (only for stack-only types)

Implementing Display

Debug gives you {:?} output. For nicer {} output, implement Display:

use std::fmt;

struct Temperature {
    celsius: f64,
}

impl fmt::Display for Temperature {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{:.1}°C", self.celsius)
    }
}

fn main() {
    let temp = Temperature { celsius: 23.5 };
    println!("Temperature: {}", temp);  // Temperature: 23.5°C
}

Display is for user-facing output. Debug is for developer-facing output. You implement Display manually because Rust cannot guess how you want your type to look.

Multiple Impl Blocks

You can split methods across multiple impl blocks. This is useful for organizing code:

struct Player {
    name: String,
    score: u32,
    level: u32,
}

impl Player {
    fn new(name: String) -> Player {
        Player { name, score: 0, level: 1 }
    }
}

impl Player {
    fn add_score(&mut self, points: u32) {
        self.score += points;
        if self.score >= self.level * 100 {
            self.level += 1;
        }
    }

    fn summary(&self) -> String {
        format!("{} — Level {} ({} points)", self.name, self.level, self.score)
    }
}

Structs That Own vs Borrow

Structs can own their data or borrow it. For now, use owned types like String instead of &str:

// Good for beginners — struct owns its data
struct Article {
    title: String,
    body: String,
}

// Advanced — struct borrows data (requires lifetimes)
// struct ArticleRef<'a> {
//     title: &'a str,
//     body: &'a str,
// }

Structs with references need lifetime annotations. We will cover this in a future tutorial.

A Complete Example

Let’s put it all together with a BankAccount struct:

use std::fmt;

#[derive(Debug, Clone)]
struct BankAccount {
    owner: String,
    balance: f64,
}

impl BankAccount {
    fn new(owner: String, initial_balance: f64) -> BankAccount {
        BankAccount {
            owner,
            balance: initial_balance,
        }
    }

    fn deposit(&mut self, amount: f64) {
        if amount > 0.0 {
            self.balance += amount;
        }
    }

    fn withdraw(&mut self, amount: f64) -> bool {
        if amount > 0.0 && amount <= self.balance {
            self.balance -= amount;
            true
        } else {
            false
        }
    }

    fn balance(&self) -> f64 {
        self.balance
    }
}

impl fmt::Display for BankAccount {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "Account({}, ${:.2})", self.owner, self.balance)
    }
}

fn main() {
    let mut account = BankAccount::new(String::from("Alex"), 1000.0);
    println!("{}", account);  // Account(Alex, $1000.00)

    account.deposit(500.0);
    println!("After deposit: {}", account);

    let success = account.withdraw(200.0);
    println!("Withdraw success: {}", success);
    println!("Final: {}", account);
}

Summary

ConceptSyntaxPurpose
Structstruct Name { field: Type }Group related data
InstanceName { field: value }Create a value
Methodfn name(&self) in implBehavior on a type
Associated fnfn name() in implConstructors, utilities
Tuple structstruct Name(Type)Simple wrappers
Displayimpl fmt::DisplayPretty printing
Derive#[derive(Debug, Clone)]Auto-implement traits

Source Code

View source code on GitHub →

What’s Next?

Structs let you model data with named fields. But sometimes a value can be one of several different things. That is what enums are for. In the next tutorial, we learn enums and pattern matching — one of Rust’s most powerful features.

Next: Rust Tutorial #7: Enums and Pattern Matching