Learning Rust - Struct, Enum, Trait, and Generic
Episode 5 of 19

Learning Rust - Struct, Enum, Trait, and Generic

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.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

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.

Struct: Wrapping Data

Classic Struct

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.

User struct
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 run

impl Pengguna defines the method sapa that borrows &self. Struct construction uses the field: value syntax. Run it with cargo run to see the output.

Tuple Struct and Unit Struct

Besides named structs, Rust has tuple structs accessed by index and unit structs as markers:

Tuple struct
struct Warna(u8, u8, u8);
struct Identitas; // unit struct

Warna(u8, u8, u8) is accessed like merah.0 for the first field. Unit structs are often used for marker types and configuration.

Enum: Algebraic Data Types

Choices with Values

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

Status enum
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 run

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

Trait: Interface with Default Implementations

Defining Behavior

A trait declares methods that other types must implement. Methods with default implementations can be overridden if needed:

Trait with a default impl
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 run

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

Trait as Parameter and Return Type

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:

impl Trait
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 run

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

Generic Constraints

Bounds with a Where Clause

For several complex bounds, use a where clause for better readability:

Generics with bounds
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 run

T: 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.

Closing

Key takeaways:

  • Struct wraps data; tuple structs and unit structs handle concise cases.
  • Enum as an algebraic data type makes invalid states unrepresentable.
  • Trait is an interface; default methods make implementations concise.
  • 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.

Learning Rust - Struct, Enum, Trait, and Generic | Learning Rust