This episode builds data types in Rust: struct, tuple struct, and unit struct, enums with match as algebraic data types, traits as interfaces with default impls, as well as generic constraints that make code reusable without runtime cost.

After handling errors, it is time to build the data blocks that reflect the application domain. Episode 5 breaks down the three pillars of type modeling in Rust: struct for data, enum for choices, and trait for behavior — combined with generics so that code works for many types.
Type modeling is not just aesthetics. A well-designed enum can make "invalid states" unrepresentable, so many bugs disappear before they happen. This episode gives you the vocabulary to write expressive and safe APIs.
A struct is like a simple object: a collection of named fields. Fields can be changed if the instance is mut, and methods are defined in an impl block.
cat > src/main.rs <<'EOF'
struct Pengguna {
nama: String,
umur: u32,
aktif: bool,
}
impl Pengguna {
fn sapa(&self) -> String {
format!("halo, {}", self.nama)
}
}
fn main() {
let pengguna = Pengguna {
nama: String::from("Budi"),
umur: 28,
aktif: true,
};
println!("{}", pengguna.sapa());
}
EOF
cargo runimpl Pengguna defines the method sapa that borrows &self. Struct construction uses the field: value syntax. Run it with cargo run to see the output.
Besides named structs, Rust has tuple structs accessed by index and unit structs as markers:
struct Warna(u8, u8, u8);
struct Identitas; // unit structWarna(u8, u8, u8) is accessed like merah.0 for the first field. Unit structs are often used for marker types and configuration.
An enum represents one choice from several possibilities, and each variant can carry data. This is what is called an algebraic data type — a combination of "or" (enum) and "and" (struct).
cat > src/main.rs <<'EOF'
enum Status {
Baru,
Diproses { oleh: String },
Selesai(u32),
}
fn deskripsi(status: &Status) -> String {
match status {
Status::Baru => String::from("menunggu"),
Status::Diproses { oleh } => format!("diproses oleh {}", oleh),
Status::Selesai(jumlah) => format!("selesai {} item", jumlah),
}
}
fn main() {
let s = Status::Diproses {
oleh: String::from("Ani"),
};
println!("{}", deskripsi(&s));
}
EOF
cargo runmatch destructures the enum with patterns: Diproses { oleh } captures the field, Selesai(jumlah) captures the tuple value. Because match is exhaustive, adding a new variant to the enum will force you to handle it everywhere — the compiler keeps the code consistent.
A trait declares methods that other types must implement. Methods with default implementations can be overridden if needed:
cat > src/main.rs <<'EOF'
trait Keluar {
fn keluaran(&self) -> String;
fn cetak(&self) {
println!("{}", self.keluaran());
}
}
struct Server(String);
impl Keluar for Server {
fn keluaran(&self) -> String {
format!("[server] {}", self.0)
}
}
fn main() {
let server = Server(String::from("api"));
server.cetak();
}
EOF
cargo runcetak has a default implementation that calls keluaran. The Server type only needs to implement keluaran, and then gets cetak for free. This pattern is widely used by the stdlib, for example the Iterator trait.
Traits serve as generic bounds: fn cetak<T: Keluar>(item: &T) accepts any type implementing Keluar. Modern syntax uses impl Trait for parameters and return types:
cat > src/main.rs <<'EOF'
fn gabungkan(a: impl ToString, b: impl ToString) -> String {
format!("{}{}", a.to_string(), b.to_string())
}
fn main() {
let hasil = gabungkan(1, "x");
println!("{}", hasil);
}
EOF
cargo runimpl ToString on a parameter means "any type that implements ToString". The code remains monomorphic: the compiler generates a specialized version per type, so there is no dynamic dispatch overhead.
For several complex bounds, use a where clause for better readability:
cat > src/main.rs <<'EOF'
fn cetak_dua<T, U>(a: T, b: U)
where
T: std::fmt::Display,
U: std::fmt::Display,
{
println!("{} dan {}", a, b);
}
fn main() {
cetak_dua("rust", 42);
}
EOF
cargo runT: std::fmt::Display, U: std::fmt::Display states that both types can be formatted as text. Generics add flexibility without adding runtime cost — this is the essence of the zero-cost abstraction discussed in episode 2.
Key takeaways:
impl Trait for parameters and return types keeps the code monomorphic.In the next episode 6 we will discuss memory safety, borrowing, and lifetimes — the ownership and borrowing rules for immutable and mutable references, basic lifetime annotations and reference validity, as well as the practice of transferring ownership with move, clone, and copy. This is the heart of Rust's memory safety.