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
}
| Derive | Enables | When to use |
|---|---|---|
Debug | {:?} and {:#?} | Almost always — for debugging |
Clone | .clone() | When you need explicit copies |
Copy | Automatic copy semantics | Small types (all fields must be Copy) |
PartialEq | == and != | Equality comparison |
Eq | Total equality guarantee | Together with PartialEq for HashMap |
PartialOrd | <, >, <=, >= | Partial ordering |
Ord | .sort(), .min(), .max() | Total ordering |
Hash | Used in HashMap / HashSet | Together with Eq |
Default | Struct::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).
implblocks are separate from the struct definition — data and behavior are explicitly separated, unlike OOP classes. A struct can have multipleimplblocks.- Associated functions for constructors — use
Struct::new(...)as the convention. They don’t takeselfand are called with::.- Three
selfvariants —&selffor reading,&mut selffor modifying,self(no ref) for consuming the instance (useful in the builder pattern).- Field shorthand —
Struct { 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
pubexplicitly to fields that need access from outside the module. Private fields encourage encapsulation via methods.#[derive(...)]for common traits —Debug,Clone,PartialEq,PartialOrd,Hash,Defaultcan be generated automatically. Almost always add at leastDebug.- The newtype pattern — a single-field tuple struct gives semantic meaning and type safety to primitives, preventing
UserIdfrom being mixed up withOrderIdat compile time.- Owned struct fields are simpler — use
Stringinstead of&strin struct fields to avoid lifetime annotations unless there’s a clear performance reason.