Struct #

Structs are Rust’s primary way of creating custom data types that are meaningful in your problem domain. Unlike classes in traditional OOP languages, a struct in Rust only stores data — behavior is added separately through impl blocks, and polymorphism is achieved through traits, not inheritance. This separation isn’t a limitation: it actually encourages more composable designs that are easier to test, because data and behavior can evolve independently. This article covers the three kinds of structs, how to define methods and constructors, visibility management, and the idiomatic patterns like the builder pattern and newtype that often appear in production Rust code.

The Three Kinds of Structs #

Rust has three forms of struct, each suited to a different situation:

flowchart TD
    S[Structs in Rust]
    S --> A["Named Struct\nNamed fields\nThe most commonly used"]
    S --> B["Tuple Struct\nFields accessed by index\nFor simple wrapper types"]
    S --> C["Unit Struct\nNo fields\nFor marker types and traits"]

Named Struct #

The most common form — every field has an explicit name and type:

struct Pengguna {
    nama: String,
    email: String,
    usia: u8,
    aktif: bool,
}

fn main() {
    // Initialization — all fields must be filled
    let user = Pengguna {
        nama: String::from("Budi Santoso"),
        email: String::from("[email protected]"),
        usia: 28,
        aktif: true,
    };

    // Field access with dot notation
    println!("Name: {}", user.nama);
    println!("Email: {}", user.email);

    // Mutable struct — all fields become mutable
    let mut user2 = Pengguna {
        nama: String::from("Sari"),
        email: String::from("[email protected]"),
        usia: 25,
        aktif: true,
    };
    user2.usia = 26;  // change a field
    println!("New age: {}", user2.usia);
}

Field Shorthand #

When a local variable name matches the field name, you don’t need to write nama: nama:

fn buat_pengguna(nama: String, email: String) -> Pengguna {
    Pengguna {
        nama,    // field shorthand: equivalent to nama: nama
        email,   // equivalent to email: email
        usia: 0,
        aktif: true,
    }
}

Struct Update Syntax #

Create a new instance based on an existing one, changing only the fields that differ:

struct Pengguna {
    nama: String,
    email: String,
    usia: u8,
    aktif: bool,
}

fn main() {
    let user1 = Pengguna {
        nama: String::from("Budi"),
        email: String::from("[email protected]"),
        usia: 28,
        aktif: true,
    };

    // Copy all fields from user1 except email
    let user2 = Pengguna {
        email: String::from("[email protected]"),
        ..user1  // remaining fields from user1
    };

    // NOTE: user1.nama has been moved into user2 (String isn't Copy)
    // println!("{}", user1.nama);  // error: value has been moved
    println!("{}", user2.nama);    // "Budi" — comes from user1
    println!("{}", user2.email);   // "[email protected]"

    // Fields with Copy types (u8, bool) are still accessible from user1
    // println!("{}", user1.usia); // ✓ because u8 is a Copy type
}

Tuple Struct #

Fields are accessed by index (.0, .1, etc.), suitable for thin wrapper types that give semantic meaning to primitive types:

struct Meter(f64);
struct Kilogram(f64);
struct Warna(u8, u8, u8);  // RGB

fn cetak_jarak(jarak: Meter) {
    println!("{} meters", jarak.0);
}

fn main() {
    let tinggi = Meter(1.75);
    let berat = Kilogram(70.0);
    let merah = Warna(255, 0, 0);

    cetak_jarak(tinggi);
    println!("{} kg", berat.0);
    println!("RGB: {}, {}, {}", merah.0, merah.1, merah.2);

    // ANTI-PATTERN: mixing Meter with Kilogram without tuple structs
    // Without tuple structs, both are just f64 — the compiler can't tell them apart
    fn jarak_salah(d: f64) {}
    // jarak_salah(berat.0);  // nothing prevents this at compile time

    // CORRECT: with tuple structs, different types can't be mixed
    // cetak_jarak(berat);  // error[E0308]: expected Meter, found Kilogram ✓
}

Unit Struct #

A struct without fields, useful as a marker type or for implementing traits without needing to store data:

// Marker struct — marks a type without extra data
struct Terverifikasi;
struct BelumTerverifikasi;

struct Email<Status> {
    alamat: String,
    _status: std::marker::PhantomData<Status>,
}

// Unit struct as a trait implementor
struct Logger;

trait Catat {
    fn catat(&self, pesan: &str);
}

impl Catat for Logger {
    fn catat(&self, pesan: &str) {
        println!("[LOG] {}", pesan);
    }
}

fn main() {
    let logger = Logger;
    logger.catat("Application started");
    logger.catat("Process finished");
}

impl Blocks — Methods and Associated Functions #

Struct behavior is added through impl blocks. A struct can have several impl blocks — Rust merges them automatically.

Associated Functions as Constructors #

Associated functions don’t take self — they’re called with NamaStruct::nama_fungsi(). Most often used as constructors:

#[derive(Debug)]
struct Persegi {
    sisi: f64,
}

impl Persegi {
    // Standard constructor — `new` naming convention
    fn new(sisi: f64) -> Self {
        assert!(sisi > 0.0, "Side must be positive");
        Persegi { sisi }
    }

    // Alternative constructor with a descriptive name
    fn dari_luas(luas: f64) -> Self {
        assert!(luas > 0.0, "Area must be positive");
        Persegi { sisi: luas.sqrt() }
    }

    // Associated constant — accessed with Persegi::SISI_DEFAULT
    const SISI_DEFAULT: f64 = 1.0;
}

fn main() {
    let p1 = Persegi::new(5.0);
    let p2 = Persegi::dari_luas(25.0);  // sisi = 5.0 as well

    println!("{:?}", p1);
    println!("{:?}", p2);
    println!("Default: {}", Persegi::SISI_DEFAULT);
}

Instance Methods — &self, &mut self, self #

Instance methods always have a first parameter referring to the instance itself:

#[derive(Debug, Clone)]
struct PersegPanjang {
    lebar: f64,
    tinggi: f64,
}

impl PersegPanjang {
    fn new(lebar: f64, tinggi: f64) -> Self {
        PersegPanjang { lebar, tinggi }
    }

    // &self — reads data, doesn't modify
    fn luas(&self) -> f64 {
        self.lebar * self.tinggi
    }

    fn keliling(&self) -> f64 {
        2.0 * (self.lebar + self.tinggi)
    }

    fn diagonal(&self) -> f64 {
        (self.lebar.powi(2) + self.tinggi.powi(2)).sqrt()
    }

    fn adalah_persegi(&self) -> bool {
        (self.lebar - self.tinggi).abs() < f64::EPSILON
    }

    // &mut self — modifies instance state
    fn skalakan(&mut self, faktor: f64) {
        self.lebar *= faktor;
        self.tinggi *= faktor;
    }

    fn putar(&mut self) {
        std::mem::swap(&mut self.lebar, &mut self.tinggi);
    }

    // self (without &) — consumes the instance, returns a new self
    // Useful for the builder pattern
    fn dengan_lebar(mut self, lebar: f64) -> Self {
        self.lebar = lebar;
        self
    }

    // A method that takes another instance as a parameter
    fn bisa_muat(&self, lain: &PersegPanjang) -> bool {
        self.luas() >= lain.luas()
    }
}

fn main() {
    let mut p = PersegPanjang::new(10.0, 5.0);

    println!("Area: {}", p.luas());
    println!("Perimeter: {}", p.keliling());
    println!("Diagonal: {:.2}", p.diagonal());
    println!("Is square? {}", p.adalah_persegi());

    p.skalakan(2.0);
    println!("After 2x scaling: {:?}", p);

    p.putar();
    println!("After rotation: {:?}", p);

    // Builder-style with method chaining
    let p2 = PersegPanjang::new(1.0, 1.0)
        .dengan_lebar(8.0);
    println!("p2: {:?}", p2);

    println!("Can p fit p2? {}", p.bisa_muat(&p2));
}

Method Chaining (Builder Pattern) #

Methods that return Self allow chained calls — a very common pattern for object configuration:

#[derive(Debug)]
struct KonfigurasiServer {
    host: String,
    port: u16,
    maks_koneksi: u32,
    timeout_detik: u64,
    tls_aktif: bool,
}

impl KonfigurasiServer {
    fn baru() -> Self {
        KonfigurasiServer {
            host: String::from("127.0.0.1"),
            port: 8080,
            maks_koneksi: 100,
            timeout_detik: 30,
            tls_aktif: false,
        }
    }

    fn host(mut self, host: &str) -> Self {
        self.host = host.to_string();
        self
    }

    fn port(mut self, port: u16) -> Self {
        self.port = port;
        self
    }

    fn maks_koneksi(mut self, maks: u32) -> Self {
        self.maks_koneksi = maks;
        self
    }

    fn timeout(mut self, detik: u64) -> Self {
        self.timeout_detik = detik;
        self
    }

    fn dengan_tls(mut self) -> Self {
        self.tls_aktif = true;
        self
    }
}

fn main() {
    // All default values
    let server_lokal = KonfigurasiServer::baru();

    // Production configuration with chaining
    let server_produksi = KonfigurasiServer::baru()
        .host("0.0.0.0")
        .port(443)
        .maks_koneksi(1000)
        .timeout(60)
        .dengan_tls();

    println!("{:?}", server_lokal);
    println!("{:?}", server_produksi);
}

Field Visibility #

By default, all struct fields are private. Add pub to expose them outside the module:

mod akun {
    pub struct RekeningBank {
        pub pemilik: String,     // readable from outside the module
        pub nomor: String,       // readable from outside the module
        saldo: f64,              // PRIVATE — only accessible inside the module
    }

    impl RekeningBank {
        pub fn baru(pemilik: &str, nomor: &str, saldo_awal: f64) -> Self {
            RekeningBank {
                pemilik: pemilik.to_string(),
                nomor: nomor.to_string(),
                saldo: saldo_awal,
            }
        }

        pub fn saldo(&self) -> f64 {
            self.saldo  // expose saldo only through a getter method
        }

        pub fn setor(&mut self, jumlah: f64) -> Result<(), String> {
            if jumlah <= 0.0 {
                return Err(String::from("Deposit amount must be positive"));
            }
            self.saldo += jumlah;
            Ok(())
        }

        pub fn tarik(&mut self, jumlah: f64) -> Result<f64, String> {
            if jumlah <= 0.0 {
                return Err(String::from("Withdrawal amount must be positive"));
            }
            if jumlah > self.saldo {
                return Err(format!("Insufficient balance: {}", self.saldo));
            }
            self.saldo -= jumlah;
            Ok(jumlah)
        }
    }
}

fn main() {
    let mut rek = akun::RekeningBank::baru("Budi", "001-234-567", 1_000_000.0);

    println!("Owner: {}", rek.pemilik);   // ✓ pub field
    println!("Number: {}", rek.nomor);       // ✓ pub field
    // println!("{}", rek.saldo);           // ✗ error: private field

    println!("Balance: {}", rek.saldo());     // ✓ via pub method

    rek.setor(500_000.0).unwrap();
    println!("After deposit: {}", rek.saldo());

    match rek.tarik(200_000.0) {
        Ok(jumlah) => println!("Withdrawn: {}", jumlah),
        Err(e) => println!("Failed: {}", e),
    }
}

Derive Macros — Automatic Traits #

Rust provides #[derive(...)] to automatically implement common traits based on the field structure. This avoids repetitive boilerplate:

#[derive(
    Debug,      // println!("{:?}", ...) and println!("{:#?}", ...)
    Clone,      // .clone() to make a copy
    PartialEq,  // == and !=
    PartialOrd, // <, >, <=, >=
)]
struct Titik {
    x: f64,
    y: f64,
}

#[derive(Debug, Clone, PartialEq)]
struct Segitiga {
    a: Titik,
    b: Titik,
    c: Titik,
}

fn main() {
    let p1 = Titik { x: 0.0, y: 0.0 };
    let p2 = Titik { x: 3.0, y: 4.0 };
    let p3 = p1.clone();

    println!("{:?}", p1);                // Debug
    println!("{:?}", p2);
    println!("p1 == p3: {}", p1 == p3); // PartialEq
    println!("p1 < p2: {}", p1 < p2);  // PartialOrd

    let t1 = Segitiga {
        a: Titik { x: 0.0, y: 0.0 },
        b: Titik { x: 3.0, y: 0.0 },
        c: Titik { x: 0.0, y: 4.0 },
    };
    let t2 = t1.clone();
    println!("t1 == t2: {}", t1 == t2); // PartialEq on a nested struct
}
DeriveEnablesWhen to use
Debug{:?} and {:#?}Almost always — for debugging
Clone.clone()When you need explicit copies
CopyAutomatic copy semanticsSmall types (all fields must be Copy)
PartialEq== and !=Equality comparison
EqTotal equality guaranteeTogether with PartialEq for HashMap
PartialOrd<, >, <=, >=Partial ordering
Ord.sort(), .min(), .max()Total ordering
HashUsed in HashMap / HashSetTogether with Eq
DefaultStruct::default()Default values for all fields

Ownership Inside Structs #

Struct fields follow ownership rules. A struct that stores references needs a lifetime annotation — that’s an advanced topic, but worth knowing from the start:

// ANTI-PATTERN: a struct storing &str without a lifetime
// struct NamaTanpaLifetime {
//     nama: &str,  // error: missing lifetime specifier
// }

// CORRECT: use String (owned) if the struct needs to own its data
#[derive(Debug)]
struct Produk {
    nama: String,       // owned — the struct owns this string
    harga: f64,
    stok: u32,
}

// CORRECT: or use a lifetime if the struct only borrows data
#[derive(Debug)]
struct ProdukRef<'a> {
    nama: &'a str,      // borrowed — the struct only borrows, doesn't own
    harga: f64,
}

fn main() {
    // Owned Produk — its data lives with the struct
    let p = Produk {
        nama: String::from("Laptop"),
        harga: 15_000_000.0,
        stok: 10,
    };

    // Borrowed ProdukRef — its data must outlive the struct
    let nama = String::from("Monitor");
    let pref = ProdukRef {
        nama: &nama,
        harga: 3_500_000.0,
    };

    println!("{:?}", p);
    println!("{:?}", pref);
}

The Newtype Pattern #

A single-field tuple struct is often used as a newtype — wrapping a primitive type to add semantic meaning and extra type safety:

struct UserId(u64);
struct OrderId(u64);
struct Rupiah(f64);
struct Dolar(f64);

fn proses_pesanan(user: UserId, order: OrderId, total: Rupiah) {
    println!(
        "User {} ordered #{} for Rp{:.0}",
        user.0, order.0, total.0
    );
}

fn konversi_ke_rupiah(dolar: Dolar, kurs: f64) -> Rupiah {
    Rupiah(dolar.0 * kurs)
}

fn main() {
    let user = UserId(1001);
    let order = OrderId(5042);
    let harga_dolar = Dolar(99.99);
    let kurs = 15_800.0;

    let harga_rupiah = konversi_ke_rupiah(harga_dolar, kurs);
    proses_pesanan(user, order, harga_rupiah);

    // ANTI-PATTERN: without newtypes, easy to mix up
    fn proses_tanpa_tipe(user_id: u64, order_id: u64) {}
    // proses_tanpa_tipe(5042, 1001);  // swapped order — no error!

    // CORRECT: with newtypes, the compiler catches this mistake
    // proses_pesanan(OrderId(5042), UserId(1001), ...);  // type error ✓
}

Summary #

  • Three kinds of structs — named struct (named fields, most common), tuple struct (fields accessed by index, for newtypes), unit struct (no fields, for markers/traits).
  • impl blocks are separate from the struct definition — data and behavior are explicitly separated, unlike OOP classes. A struct can have multiple impl blocks.
  • Associated functions for constructors — use Struct::new(...) as the convention. They don’t take self and are called with ::.
  • Three self variants&self for reading, &mut self for modifying, self (no ref) for consuming the instance (useful in the builder pattern).
  • Field shorthandStruct { nama, email } when local variable names match field names.
  • Struct update syntax ..instance — copy unspecified fields from another instance. Note: non-Copy fields will be moved.
  • Field visibility is private by default — add pub explicitly to fields that need access from outside the module. Private fields encourage encapsulation via methods.
  • #[derive(...)] for common traitsDebug, Clone, PartialEq, PartialOrd, Hash, Default can be generated automatically. Almost always add at least Debug.
  • The newtype pattern — a single-field tuple struct gives semantic meaning and type safety to primitives, preventing UserId from being mixed up with OrderId at compile time.
  • Owned struct fields are simpler — use String instead of &str in struct fields to avoid lifetime annotations unless there’s a clear performance reason.

← Previous: Functions   Next: Traits →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact