Use the Type System to Express Data Structures

How Rust's type system lets you turn domain rules into compile-time checks, eliminate entire classes of bugs, and write self-documenting code through structs, enums, and newtypes

Before a program can do anything useful, it must decide what its data looks like. In many languages, that decision is mostly about convenience: pick a string or an integer, stuff a few values into a dictionary, and move on. Rust treats that same decision as the first line of defense against bugs. The type system is not a tax you pay to the compiler; it is a tool for encoding exactly what values are allowed, right into the structure of the code itself. This section shows how to make that tool work for you — how to choose structs, enums, tuple structs, and wrapper types so that the compiler rejects invalid states before the program ever runs.

This Section Assumes:

You have read the earlier parts of this chapter on defining structs, method syntax, generic structs, and deriving traits. If you are new to those concepts, reviewing them first will make the ideas here easier to follow.

What It Means to Express Data Structures Through the Type System

Every program has invariants — rules that must hold true for the data to be meaningful. A postal code should never be negative. A shopping cart in a checkout system should never contain a negative quantity. A connection should never be both "idle" and "connected" at the same time. In a language where any integer is just an i32 and any state is just a bool, it is the programmer's job to remember these rules and enforce them manually. That works fine in small programs. It breaks down when the system grows.

Rust takes a different approach. Instead of remembering the rules, you write them into the types. If a value should never be negative, the type doesn't allow it. If a state can be one of exactly three things, the type doesn't let you accidentally create a fourth. The compiler then enforces those rules everywhere the type is used, for free, forever. That is the core insight: the type system can carry the domain logic, turning what would be runtime bugs into compile-time errors.

Structs: Naming the Pieces of a Concept

A struct is the most straightforward way to bundle related values into a single unit. Unlike a tuple, every field gets a name, which makes the code self-documenting and eliminates ordering mistakes.

struct ShippingAddress {
    recipient: String,
    street: String,
    city: String,
    postal_code: String,
    country: String,
}

This definition does more than group five strings. It tells anyone reading the code that these five fields belong together and that the order in which you provide them does not matter. When you create an instance, you write ShippingAddress { recipient, street, ... } — the field names are right there, impossible to confuse.

The fields themselves are still just String. That is a good start, but it does not prevent someone from accidentally putting a city name in the postal_code field. The type system can go further.

Primitive Obsession:

Using raw String or i32 for everything is sometimes called "primitive obsession." The problem is not the primitive type itself; it is that the type does not carry any domain meaning. A String called postal_code is still just a String, and the compiler cannot tell it apart from a String called city. Later sections show how to wrap these in stronger types.

Tuple Structs: A Lightweight Wrapper When Field Names Would Be Noise

Sometimes you need to distinguish two values of the same underlying type, but naming each field feels heavy-handed. That is where tuple structs shine. They give the whole type a name while leaving the fields unnamed, accessible by position.

struct Meters(f64);
struct Feet(f64);
fn altitude(value: Meters) {
    println!("Altitude: {:.1} meters", value.0);
}
let height = Meters(8848.0);
altitude(height);
// altitude(Feet(29029.0)); // This would be a compile error

The function altitude expects a Meters, not a Feet. If you accidentally pass a Feet value, the compiler stops you. A bare f64 would have compiled without complaint, silently producing nonsense.

Tuple structs work best when the meaning of the value is obvious from the type name alone — a wrapper around a single number or string, for example. As soon as you have multiple fields that are easy to mix up, a named-field struct is clearer.

Enums: The Compiler-Enforced List of Possibilities

If structs let you say "this data always has these fields," enums let you say "this value is exactly one of these things, and nothing else." That is already useful for simple variants:

enum PrintSide {
    Both,
    Single,
}
enum OutputMode {
    BlackAndWhite,
    Color,
}
fn print_page(side: PrintSide, mode: OutputMode) {
    // ...
}

The alternative in many codebases is a pair of bool parameters:

fn print_page(both_sides: bool, color: bool) { /* ... */ }

At the call site, print_page(true, false) is a puzzle. What does true mean? Is the first argument the side or the color? An enum version — print_page(PrintSide::Both, OutputMode::BlackAndWhite) — is immediately readable and impossible to get wrong. The compiler also rejects any attempt to pass a value that isn't a PrintSide or OutputMode.

Data-Carrying Enums

The real power appears when enum variants carry their own data. A common pattern is modeling the result of an operation that can succeed or fail:

enum ConnectionState {
    Disconnected,
    Connecting { attempts: u32 },
    Connected { session_id: String },
    Failed { error: String },
}

A connection cannot be both Connecting and Connected at the same time because a single enum value is exactly one variant. The data that only makes sense for a particular variant — like attempts while connecting — lives inside that variant, nowhere else. You can never access session_id on a Disconnected value because the compiler knows it isn't there.

fn describe(state: &ConnectionState) -> String {
    match state {
        ConnectionState::Disconnected => "Not connected".to_string(),
        ConnectionState::Connecting { attempts } => {
            format!("Connecting, attempt {}", attempts)
        }
        ConnectionState::Connected { session_id } => {
            format!("Connected with session {}", session_id)
        }
        ConnectionState::Failed { error } => format!("Failed: {}", error),
    }
}

The match expression must handle every variant. Forget one, and the code will not compile. When you later add a new variant, the compiler points to every match that needs updating, so you never miss a case by accident.

Exhaustiveness Is a Safety Net:

If you fail to handle a variant in a match, the compiler error is E0004: non-exhaustive patterns. This error is not a nuisance; it is the type system preventing the equivalent of a null-pointer dereference or a missing branch that would silently do the wrong thing. Always pay attention to it.

Making Illegal States Unrepresentable

The phrase "make illegal states unrepresentable" is a guiding principle in Rust design. It means shaping your types so that invalid combinations of data cannot even be constructed. The compiler becomes the guardrail.

Consider a function that accepts a file path and an open mode. A typical implementation might take a String and a bool:

fn open_file(path: &str, read_only: bool) { /* ... */ }

What prevents a caller from setting read_only = false but forgetting to also provide write permissions? Nothing, in the type system. You would need to check at runtime and return an error.

Now look at a type-driven approach:

enum FileMode {
    ReadOnly,
    ReadWrite,
    WriteOnly,
}
fn open_file(path: &str, mode: FileMode) { /* ... */ }

There is no longer a concept of an inconsistent combination of flags. The three allowed modes are spelled out, and no fourth option exists. A caller cannot express "read_only = false but also no write permission" because that concept does not map to any variant.

The same idea applies to numerical ranges. If a type should only hold values between 1 and 12 (months of the year), a raw u8 invites mistakes. A wrapper type with a constructor that validates the value closes that door:

struct Month(u8);
impl Month {
    fn new(value: u8) -> Option<Self> {
        if value >= 1 && value <= 12 {
            Some(Month(value))
        } else {
            None
        }
    }
}

Now the only way to obtain a Month is through Month::new, which returns Option<Month>. Any code that receives a Month knows for certain that it holds a valid month, with no further checks needed.

Invariants in the Type, Not in Comments:

When you enforce a constraint in the type constructor, you remove the need for runtime assertions scattered through the codebase and the need for comments that say "this must be between 1 and 12." The type itself is the documentation.

Replacing Booleans and Strings with Purpose-Built Types

Booleans are the most overloaded type in programming. A single bool carries no meaning beyond true or false, and a pair of them creates four combinations, some of which might be nonsense. Strings are similarly vague: "admin" and "Admin" and "ADMIN" are all different strings but probably mean the same role.

Rust encourages you to replace bare booleans and strings with custom types, often enums, as soon as the domain meaning is more specific than "yes or no."

// Before: two booleans, meaning buried in parameter order
fn configure(encryption: bool, compression: bool) { /* ... */ }
// After: one enum per concern
enum Encryption {
    Enabled,
    Disabled,
}
enum Compression {
    Enabled,
    Disabled,
}
fn configure(encryption: Encryption, compression: Compression) { /* ... */ }

At first glance, this looks like more code for the same result. The difference is at the call site. configure(Encryption::Enabled, Compression::Disabled) will never be accidentally written as configure(true, false) with the booleans swapped. The compiler catches any mismatch because Encryption and Compression are distinct types, even though both happen to have Enabled and Disabled variants.

For strings that represent a fixed set of categories, an enum is almost always the right choice:

enum UserRole {
    Admin,
    Editor,
    Viewer,
}
fn assign_role(user_id: u64, role: UserRole) { /* ... */ }

A String parameter would allow "adimn", "superadmin", or any other typo. An enum parameter allows exactly the three variants defined in the source code. The compiler rejects everything else.

The Newtype Pattern: Strong Typing for Single Values

When an enum is too heavy — perhaps the value will always be a number, but it must carry a specific meaning — the newtype pattern wraps a single underlying type in a tuple struct.

struct UserId(u64);
struct OrderId(u64);

UserId and OrderId are both u64 at runtime, but they are completely distinct types. A function that expects a UserId will not accept an OrderId, and vice versa. This prevents an entire class of bugs where IDs from different entities are accidentally interchanged.

The newtype pattern also lets you attach methods that make sense only for that wrapper:

impl UserId {
    fn from_env() -> Option<Self> {
        std::env::var("USER_ID")
            .ok()?
            .parse()
            .ok()
            .map(UserId)
    }
}

A bare u64 cannot know how to read itself from an environment variable. A UserId can.

Newtype vs Enum:

Use a newtype when the set of allowed values is essentially the same as the underlying type — all u64 values are technically valid IDs, you just want to tag them. Use an enum when the set of allowed values is a small, fixed list. If the list might grow later, an enum is also easier to extend without breaking callers (when combined with non-exhaustive patterns).

Type-Driven API Design in Practice

Putting these pieces together, a well-designed Rust API uses types to tell the user exactly what to provide and what to expect back. The compiler becomes a silent guide.

Imagine a function that sends a notification. A naive signature might look like this:

fn send_notification(to: &str, message: &str, priority: u8) { /* ... */ }

Several things are wrong here. to could be an empty string. message could be empty. priority could be 42, which might be invalid. The function body would need to validate all of these, and every caller would need to handle the errors. A type-driven rewrite eliminates the problem at the boundary:

struct EmailAddress(String);
struct NotificationMessage(String);
enum Priority { Low, Normal, High }
impl EmailAddress {
    fn new(addr: &str) -> Option<Self> {
        // simplified validation
        if addr.contains('@') {
            Some(EmailAddress(addr.to_string()))
        } else {
            None
        }
    }
}
impl NotificationMessage {
    fn new(msg: &str) -> Option<Self> {
        if msg.is_empty() {
            None
        } else {
            Some(NotificationMessage(msg.to_string()))
        }
    }
}
fn send_notification(to: EmailAddress, message: NotificationMessage, priority: Priority) {
    // No validation needed here — the types guarantee validity.
}

The function send_notification now has a tight contract. You cannot call it with a raw string or an arbitrary integer. You must first convert your data into the approved types. If conversion fails (None), the caller must decide what to do, which is exactly where the decision belongs — not buried inside send_notification.

This approach does add boilerplate, but the boilerplate lives near the edges of the system (input parsing, configuration loading) rather than in the core logic. The core logic becomes simpler because it can assume the data is already valid.

Common Mistakes When Encoding Domain Rules in Types

Several pitfalls show up regularly when developers first start using the type system as a design tool.

Creating types that are still too wide. Wrapping a String in a newtype struct named EmailAddress is a good start, but if the constructor EmailAddress::new does not validate the format, the type provides no real guarantee. The name EmailAddress suggests a valid email address, but the compiler only sees a String in a wrapper. Always pair a newtype with a constructor that enforces the invariant.

Using an enum when a newtype would suffice. If a value is always a u64 and you only want to prevent mixing it with other u64 values, a tuple struct is simpler and carries no runtime overhead from discriminant matching. Reserve enums for when there are genuinely multiple distinct states.

Over-nesting enums and structs. It is possible to encode every possible constraint in types and end up with a maze of nested types that no one can read. Types should clarify the code, not obscure it. If a function takes a deeply nested structure that requires ten lines to construct, the type system might be fighting you rather than helping. Sometimes a simple validation step at the entry point is better than a tower of wrapper types.

Assuming match always forces you to list all variants. In early design, an enum may have only two variants. You write match with those two arms. Later, you add a third variant and the compiler errors on every match. This is a feature, but it can be surprising if you are not used to it. Use the _ wildcard arm sparingly — it silences the exhaustiveness check and can hide the fact that a new variant was added. A better pattern for future-proofing is to use #[non_exhaustive] on public enums so that external code must include a wildcard arm, while internal code remains strictly checked.

#[non_exhaustive]
pub enum FileMode {
    ReadOnly,
    ReadWrite,
}

Now external crates must handle the possibility of additional variants in the future, while your own crate still gets exhaustive checking.

Don't Silence the Compiler Without Thinking:

Using _ => {} in a match is tempting when you don't care about some variants, but it means that if a new variant is added, that arm silently runs instead of alerting you. A deliberate _ => unreachable!() macro call is sometimes better if you are certain a variant cannot occur in a given context, because it will loudly panic if your assumption is ever wrong, giving you a clear signal during testing.

How Beginners Should Think About the Type System

If you are new to Rust, the type system can feel like a wall of rules that stop you from getting anything done. A more productive mental model is to see it as a conversation with the compiler. You tell the compiler what your data looks like and what it is allowed to do. The compiler answers back with "here are the cases you forgot to handle" or "that value could be missing — what should I do then?" Every compiler error is the type system doing a review of your logic, for free, before the code ships.

When you write a new struct or enum, ask yourself: "Can this type ever hold a value that makes no sense?" If the answer is yes, see if you can narrow the type so that it cannot. Often, that means replacing a String with a newtype, or a bool with a two-variant enum, or an i32 with a wrapper that rejects invalid ranges. Each time you do this, you move one invariant out of your head and into the code where the compiler can enforce it.

Summary

The type system is the most underused tool in a programmer's kit. In Rust, it is front and center. Structs give names to groups of related data. Tuple structs provide lightweight wrappers for single values that need their own identity. Enums enumerate the exact set of states something can be in, with each variant optionally carrying its own data. Together, these building blocks let you write types that make illegal states impossible to express, replace error-prone booleans and strings with self-documenting alternatives, and push validation to the edges of the system where it belongs.

The result is code that fails at compile time rather than in production, and APIs that guide the caller toward correct usage without requiring documentation to explain what the parameters mean.