Building Blocks: Why User-Defined Types Are Rust's Secret Weapon for Taming Complexity
Discover how Rust’s structs, enums, and unions let you model reality with total type safety, zero hidden costs, and compile-time correctness.
Building Blocks: How Rust's Structs, Enums, and Unions Let You Model Reality Without Lying to Your Compiler
Every programmer eventually faces the same moment of reckoning. You're working on a real problem—a financial system, a game engine, a medical records platform—and you realize that primitive types aren't cutting it anymore. You can't model a patient with a String and three i32s floating around in different variables, hoping nobody accidentally passes the patient's age where the blood pressure was supposed to go. You need a way to bundle related data together, give it a meaningful name, and tell the compiler "this is a thing—treat it like one."
In C, you get struct and union—powerful but raw, with no built-in safety net and a tendency to let you do incredibly dangerous things if you're not careful (or even if you are). In Java, you get classes—which come with inheritance hierarchies so deep that your IDE needs a scroll bar just to display the type tree, plus mandatory heap allocation for everything, plus null pointers lurking behind every object reference like uninvited guests at a party.
Rust takes a different path. It gives you three tools for creating user-defined types—struct, enum, and union—each designed with the same philosophy that pervades the entire language: be powerful, be safe, and be honest about what you're doing. No hidden costs. No surprise behaviors. No pretending a union is safe when it categorically isn't.
At RantAI, where we build AI platforms and scientific computing systems that model complex real-world domains, the ability to create expressive, type-safe data structures isn't a nice-to-have—it's the backbone of everything we write. A well-designed struct or enum communicates intent more clearly than any documentation, catches misuse at compile time rather than in production, and makes refactoring a guided process rather than a prayer session. This article, inspired by Chapter 2, Section 2.8 of our free guide "The Rust Programming Language," covers the fundamentals of user-defined types in Rust and why they change how you think about modeling data.
Structs: Your Data, Your Rules
A struct in Rust is a way to group related values under a single, named type. If you've used structs in C or Go, the concept is familiar. But Rust's structs come with some key differences that make them considerably more useful—and considerably harder to misuse.
struct Vector {
x: f64,
y: f64,
z: f64,
}
fn main() {
let v = Vector {
x: 1.0,
y: 2.0,
z: 3.0,
};
println!("x={}, y={}, z={}", v.x, v.y, v.z);
}
Simple enough. You define the fields, you create an instance by naming every field explicitly (no positional ambiguity, no "wait, is the second argument width or height?"), and you access fields with dot notation. The compiler knows the exact memory layout, the exact size, and the exact alignment of every field.
But here's what makes Rust structs different from their C counterparts: they participate fully in Rust's ownership and borrowing system. You can't have a dangling reference to a struct field. You can't accidentally alias a mutable reference. You can't forget to initialize a field—the compiler requires every field to have a value at construction time. In C, an uninitialized struct field is a bug waiting to happen. In Rust, it's a compilation error happening right now.
Initializing with Precision
Rust offers a convenient shorthand when your variable names match your field names:
fn create_vector(x: f64, y: f64, z: f64) -> Vector {
Vector { x, y, z } // Field init shorthand — clean, no repetition
}
And when you want to create a new struct based on an existing one with just a few changes, the struct update syntax saves you from repeating every unchanged field:
let v1 = Vector { x: 1.0, y: 2.0, z: 3.0 };
let v2 = Vector { x: 4.0, ..v1 }; // y and z come from v1
This is the kind of small ergonomic detail that makes Rust code feel pleasant to write. No boilerplate, no repetition, just express the differences and let the compiler handle the rest.
Tuple Structs: When You Want a Name Without the Ceremony
Sometimes you need a lightweight wrapper around one or two values—enough to give the type a distinct identity, but not enough to justify named fields:
struct Meters(f64);
struct Seconds(f64);
fn speed(distance: Meters, time: Seconds) -> f64 {
distance.0 / time.0
}
Now Meters and Seconds are different types, even though both wrap an f64. You can't accidentally pass a Seconds value where a Meters is expected—the compiler will stop you. This is the "newtype pattern," and it's one of those techniques that seems trivially simple until you realize it prevents an entire class of unit-confusion bugs. (Remember when NASA lost a $125 million Mars orbiter because one team used metric units and another used imperial? A newtype pattern would have caught that at compile time. For free.)
Unit Structs: Types with No Data
struct Marker;
A struct with no fields. Zero bytes. Sounds useless? It's actually surprisingly handy for type-level programming—marking types, implementing traits on a zero-cost sentinel value, or creating distinct types that exist purely for the compiler's benefit with no runtime overhead whatsoever.
Enums: When Your Data Can Be One of Several Things
If structs model "this AND that" (a Vector has an x AND a y AND a z), enums model "this OR that"—a value that can take one of several distinct forms.
enum Color {
Red,
Green,
Blue,
}
fn color_to_hex(c: Color) -> &'static str {
match c {
Color::Red => "#FF0000",
Color::Green => "#00FF00",
Color::Blue => "#0000FF",
}
}
At first glance, this looks like a C enum—named integer constants. And for simple cases like this, it behaves similarly. But Rust enums are secretly much more powerful than C enums, and we'll explore that power in detail in a later article. For now, know that the match expression requires you to handle every variant—miss one, and the compiler will tell you. No more forgotten cases. No more default branches that silently swallow unexpected values.
Enums as Discriminated Unions
Here's where Rust enums leave C enums in the dust: each variant can carry data.
enum Shape {
Circle(f64), // radius
Rectangle(f64, f64), // width, height
Triangle(f64, f64, f64), // three sides
}
fn area(s: &Shape) -> f64 {
match s {
Shape::Circle(r) => std::f64::consts::PI * r * r,
Shape::Rectangle(w, h) => w * h,
Shape::Triangle(a, b, c) => {
let s = (a + b + c) / 2.0;
(s * (s - a) * (s - b) * (s - c)).sqrt()
}
}
}
This is a tagged union—a value that can be one of several types, with the compiler tracking which variant is active and ensuring you never access the wrong one. In C, you'd use a union plus an integer tag, and pray that nobody forgets to check the tag before accessing the union's fields. (Narrator: someone always forgets to check the tag.) In Rust, the compiler enforces correctness. You literally cannot access a Circle's radius when the value is a Rectangle. The type system makes it impossible.
Unions: The Escape Hatch for C Interop
Rust also has union—untagged unions where all fields share the same memory, just like C unions. They exist primarily for C FFI (Foreign Function Interface) compatibility:
union MyUnion {
f: f32,
i: i32,
}
fn main() {
let u = MyUnion { f: 3.14 };
let value = unsafe { u.i }; // Reading requires unsafe — you're on your own
println!("Reinterpreted bits: {}", value);
}
Notice the unsafe block. Reading from a union field requires unsafe because the compiler can't verify which field was last written—it doesn't track union state the way it tracks enum variants. This is by design: unions are inherently unsafe (the data could be anything), and Rust makes that danger explicit rather than pretending it doesn't exist.
In practice, you'll use enum for virtually everything where you need "one of several types." union is reserved for C interop and very specific performance-critical scenarios where you need exact memory layout control. If you're reaching for union in your first year of Rust, you're probably looking for enum.
Broader Implications: Types as Documentation That Never Goes Stale
Here's something that doesn't get said often enough about user-defined types: they are the best documentation your code will ever have. A function signature like fn process(shape: &Shape) -> Area tells you everything you need to know about what goes in and what comes out. The types enforce the contract. The compiler verifies the contract. And unlike a comment that says "pass a shape here," the types can't become outdated, can't be ignored, and can't be wrong.
At RantAI, our AI platform models involve dozens of domain-specific types—configuration structures, processing pipeline stages, result containers, error categories. Each one is a struct or enum that makes illegal states unrepresentable. If a processing stage can be in one of five states, it's an enum with five variants—not a string that could be anything, not an integer that could be out of range, but a type that can only ever hold valid values. The compiler enforces this at every boundary, in every function, across every module.
The result? When we refactor—and we refactor constantly, because that's what happens in a growing system—the compiler tells us every place that needs updating. Change an enum variant? The compiler flags every match that doesn't handle it. Add a struct field? The compiler flags every construction site that doesn't initialize it. It's like having an infinitely patient code reviewer who never misses anything and never gets tired.
Practical Applications & Strategic Takeaways
For newcomers: Start with structs for grouping related data, and enums for values that can be one of several things. These two tools will cover 95% of your data modeling needs. Don't reach for union unless you have a specific C interop requirement—and even then, think twice.
For C/C++ veterans: Rust enums are not C enums. This is worth repeating. Rust enums with data variants are closer to C++ std::variant (but better, because the compiler enforces exhaustive handling). The sooner you start thinking of them as tagged unions with compiler-enforced safety, the sooner you'll start using them effectively.
For architects and team leads: The newtype pattern (wrapping a primitive in a tuple struct) is absurdly effective at preventing unit-confusion bugs, parameter-ordering bugs, and semantic misuse. It costs nothing at runtime—Rust optimizes the wrapper away—and it catches bugs at compile time that would otherwise require careful code review or runtime validation. Meters(f64) vs Seconds(f64) prevents bugs that f64 vs f64 cannot.
Our Commitment to Open Knowledge
RantAI believes that understanding user-defined types is essential to writing effective Rust code. Everything else—traits, generics, ownership patterns, error handling—builds on the foundation of structs and enums. Get these right, and the rest of the language falls into place with surprising clarity.
The concepts discussed in this article are explored in depth in Chapter 2, Section 2.8 of our guide, "The Rust Programming Language," freely available online.
Explore these concepts further: https://trpl.rantai.dev
Support Our Mission & Get Your Handbook
If this exploration of Rust's type system has helped you see data modeling in a new light, consider supporting RantAI's educational mission by purchasing the handbook.
Get the Handbook on Amazon KDP: https://www.amazon.com/dp/B0DHCMD3F2
Get the Handbook on Google Play Books: https://play.google.com/store/books/details?id=INwfEQAAQBAJ
What's the most useful struct or enum you've defined in Rust? Or what's the worst "stringly typed" API you've encountered in another language that could have been prevented with proper types? Share your stories below—we learn best from each other's victories and disasters alike.
#RustLang #DataModeling #Structs #Enums #TypeSafety #RantAI #LearnRust #SystemsProgramming #SoftwareEngineering #CleanCode
Want to learn more?
Connect with our team to discuss how AI can transform your enterprise.