Modeling Data with Rust's Type System


In this post

A program can often represent more situations than the domain allows. Consider a connection with two independent fields:

struct Connection {
    is_connected: bool,
    socket: Option<String>,
}

If we treat each field as a choice, this struct has four possible shapes: connected or disconnected, with or without a socket. Two shapes are contradictory. A disconnected connection should not have a socket, and a connected connection should not be missing one.

The compiler cannot see that relationship. It sees a Boolean and an optional string, both of which are valid on their own. Every function that uses Connection must remember the rule and check it again. A future change can update one field and forget the other.

A better Rust design puts the alternatives into the type itself. Then the code that creates a value has to choose a meaningful state, and the code that reads it has to deal with the states that really exist.

This is the central idea of type-driven data modeling: use types to carry the rules that are important to the domain. The aim is not to create a type for every variable. The aim is to stop unrelated values from being confused and to make invalid combinations difficult or impossible to construct.

Types make some operations explicit

A type does more than tell Rust how many bytes a value needs. It limits the values that can be used in a particular place and tells the reader what those values mean.

For example, Rust does not silently put an i32 into an i16, even when the current number would fit:

let count: i32 = 42;
let small_count: i16 = count; // does not compile

The conversion must be explicit because some possible i32 values cannot fit into an i16. A checked conversion can report that problem instead of hiding it:

fn narrow(count: i32) -> Result<i16, std::num::TryFromIntError> {
    let small_count: i16 = count.try_into()?;
    Ok(small_count)
}

The ? operator passes the conversion error to the caller. The code has made an important decision visible: this conversion is checked, and the operation can fail.

The same principle applies to types that we define ourselves. If a function accepts a plain u16, the caller can pass any u16, including zero:

fn open_port(port: u16) {
    // ...
}

open_port(0); // compiles, even if zero is invalid for this domain

A parameter with a narrower type makes the requirement visible at the call site:

use std::num::NonZeroU16;

fn open_non_zero_port(port: NonZeroU16) {
    // ...
}

let port = NonZeroU16::new(8080).expect("8080 is non-zero");
open_non_zero_port(port);

Now the caller must construct a NonZeroU16. NonZeroU16::new(0) returns None, so invalid input has to be handled before open_non_zero_port can be called. A domain-specific newtype can later wrap this standard type and give the error a name that fits the application.

Structs describe data that exists together

A struct contains all of its fields at the same time:

struct Window {
    width: u32,
    height: u32,
}

A window has both a width and a height, so a struct is a natural representation. A tuple can serve a similar purpose for a small group of values, but named fields usually make the meaning clearer.

A struct is sometimes described as a product typeIn programming languages and type theory, a product of types is another, compounded, type in a structure. The "operands" of the product are types, and the structure of a product type is determined by the fixed order of the operands in the product. An instance of a product type retains the fixed order, but otherwise may contain all possible instances of its primitive data types. The expression of an instance of a product type will be a tuple, and is called a "tuple type" of expression. A product of types is a direct product of two or more types.Read on Wikipedia →. This does not refer to a shop or a product in the business sense. It means that a value contains this field and that field. If a struct has two independent Boolean fields, all four combinations are available unless some other rule prevents them.

That is useful when all combinations are meaningful. It is awkward when the fields describe alternatives.

Enums describe alternatives

An enum value contains one of its variants. Rust’s enum syntax is more expressive than a list of named numbers because each variant can also carry its own data.

We can rewrite the connection example as a set of mutually exclusive states:

enum Connection {
    Disconnected,
    Connecting { attempt: u32 },
    Connected { peer: String },
    Failed { reason: String },
}

A value is now either disconnected, connecting, connected, or failed. The data needed by each state lives inside that state. There is no separate socket field that can accidentally remain present after the connection becomes disconnected.

This is an example of an algebraic data typeIn computer programming, especially in functional programming and type theory, an algebraic data type (ADT) is a composite data type, i.e. a type formed by combining other types.Read on Wikipedia →. A struct combines fields that exist together. An enum combines alternatives that cannot exist together in one value. In type-theory terms, structs are product types and enums are sum typesIn computer science, a tagged union, also called a variant, variant record, choice type, discriminated union, disjoint union, sum type, or coproduct, is a data structure used to hold a value that could take on several different, but fixed, types. Only one of the types can be in use at any one time, and a tag field explicitly indicates which type is in use. It can be thought of as a type that has several "cases", each of which should be handled correctly when that type is manipulated. This is critical in defining recursive datatypes, in which some component of a value may have the same type as that value, for example in defining a type for representing trees, where it is necessary to distinguish multi-node subtrees and leaves. Like ordinary unions, tagged unions can save storage by overlapping storage areas for each type, since only one is in use at a time.Read on Wikipedia →.

The distinction is practical. If a display is either monochrome or uses a foreground colour, this struct leaves an invalid combination available:

struct DisplayProps {
    monochrome: bool,
    foreground: (u8, u8, u8),
}

The comment “ignore foreground when monochrome is true” would describe a rule that the type does not enforce. An enum can put the rule into the structure:

struct DisplayProps {
    color: Color,
}

struct RgbColor {
    red: u8,
    green: u8,
    blue: u8,
}

enum Color {
    Monochrome,
    Foreground(RgbColor),
}

Color::Monochrome has no colour to ignore. Color::Foreground always has one. The compiler cannot prove that every RgbColor is visually useful, but it can prove that a monochrome value does not contain a foreground colour at the same time.

Match the states you have

When code reads an enum, a match expression makes it handle the variants:

fn describe(connection: &Connection) -> String {
    match connection {
        Connection::Disconnected => "not connected".to_owned(),
        Connection::Connecting { attempt } => {
            format!("connecting, attempt {attempt}")
        }
        Connection::Connected { peer } => format!("connected to {peer}"),
        Connection::Failed { reason } => format!("failed: {reason}"),
    }
}

Rust checks that this match is exhaustive. If someone adds a variant and forgets to update this function, the compiler reports the missing case. The exhaustive match rule and catch-all _ pattern are useful when deciding whether a default arm is appropriate.

A catch-all arm is useful when the program has a deliberate default behaviour:

match connection {
    Connection::Connected { peer } => println!("connected to {peer}"),
    _ => println!("not ready"),
}

It also gives up some protection. If a new variant is added later, this code will still compile and silently use the default. An explicit arm is usually better when each state needs different behaviour.

if let and let ... else are shorter forms for cases where one variant is the normal path and the other cases leave the function or choose a default:

fn connected_peer(connection: &Connection) -> Option<&str> {
    let Connection::Connected { peer } = connection else {
        return None;
    };

    Some(peer)
}

The pattern is still checked by the compiler. The else block must not return normally. It usually uses return, break, continue, or a panic (the reference rule). This keeps the successful path at the main indentation level. The same pattern is useful whenever one case continues and all other cases leave the current scope.

Use named types instead of ambiguous primitives

A Boolean is appropriate when a value really has only two stable meanings. It becomes difficult to read when a function accepts several Boolean arguments:

print_page(true, false);

A caller has to remember which argument means what. Named enum values make the call self-explanatory and prevent the arguments from being swapped:

enum Sides {
    Single,
    Both,
}

enum Output {
    BlackAndWhite,
    Color,
}

fn print_page(sides: Sides, output: Output) {
    // ...
}

print_page(Sides::Both, Output::BlackAndWhite);

Use an enum when the alternatives have domain names or may grow later. Keep a Boolean when the distinction is genuinely just yes or no and introducing a new named type would add extra code without adding meaning.

The same problem appears with primitive values that have different meanings. An account identifier, an order identifier, and a port number might all fit into a u64 or u16, but they are not interchangeable. Passing the wrong one is a domain error, not a numerical error.

A small wrapper around a value is called a newtype. Its main benefit is that the wrapper is a different Rust type:

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct AccountId(u64);

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct OrderId(u64);

fn load_order(id: OrderId) {
    // ...
}

let account = AccountId(7);
// load_order(account); // does not compile

A newtype does not automatically validate its inner value. AccountId(0) is still possible in this example. The field has no pub, so only this module can construct it directly; code outside the module would need a validating constructor, as Port has below.

Keep invariants behind a constructor

Suppose a network port must be greater than zero. A plain u16 cannot express that requirement. A newtype around NonZeroU16 can:

use std::num::NonZeroU16;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(transparent)]
pub struct Port(NonZeroU16);

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PortError {
    Invalid,
    Zero,
}

impl Port {
    pub fn get(self) -> u16 {
        self.0.get()
    }
}

impl TryFrom<u16> for Port {
    type Error = PortError;

    fn try_from(value: u16) -> Result<Self, Self::Error> {
        NonZeroU16::new(value)
            .map(Self)
            .ok_or(PortError::Zero)
    }
}

impl std::str::FromStr for Port {
    type Err = PortError;

    fn from_str(text: &str) -> Result<Self, Self::Err> {
        let value = text.parse::<u16>().map_err(|_| PortError::Invalid)?;
        Self::try_from(value)
    }
}

The field is private, so code outside the module cannot construct Port without going through try_from. Once construction succeeds, functions that receive a Port do not need to check for zero again.

This is a smart constructor: a constructor that refuses values which do not satisfy the type’s invariant. TryFrom is a standard place for a fallible conversion (its trait definition), and Port::get is the controlled way to recover the primitive value when a library call needs it.

The #[repr(transparent)] attribute is useful when the wrapper must have the same layout and ABIAn application binary interface (ABI) is an interface exposed by software that is defined for in-process machine code access. Often, the exposing software is a library, and the consumer is a program.Read on Wikipedia → (the binary calling and layout rules) as its one non-zero-sized field, for example at an FFIA foreign function interface (FFI) is a mechanism by which a program written in one programming language can call interoperable routines or make use of services written or compiled in another one. An FFI is often used in contexts where calls are made into a binary dynamic-link library.Read on Wikipedia → (foreign function interface for calling code written in another language) boundary. It is not required merely to create a newtype. The Rust type-layout rules do not make every wrapper safe to pass to every foreign API.

Parse input once, then use the result

Values from users, files, network messages, and environment variables are not yet domain values. They are text or bytes that need to be interpreted. A useful boundary is:

  1. receive the untrusted representation;
  2. parse it and check its rules;
  3. return a meaningful error if it is invalid;
  4. pass the validated type through the rest of the program.

The rest of the program should receive an Endpoint, not another String that every function has to inspect. This avoids repeating validation and makes it harder to use a raw value where a checked value is required.

Here is a small endpoint type built from the Port above:

use std::str::FromStr;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Endpoint {
    host: String,
    port: Port,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EndpointError {
    MissingPort,
    EmptyHost,
    InvalidPort(PortError),
}

impl From<PortError> for EndpointError {
    fn from(error: PortError) -> Self {
        Self::InvalidPort(error)
    }
}

impl Endpoint {
    pub fn host(&self) -> &str {
        &self.host
    }

    pub fn port(&self) -> Port {
        self.port
    }
}

impl FromStr for Endpoint {
    type Err = EndpointError;

    fn from_str(text: &str) -> Result<Self, Self::Err> {
        let Some((host, port)) = text.rsplit_once(':') else {
            return Err(EndpointError::MissingPort);
        };

        if host.is_empty() {
            return Err(EndpointError::EmptyHost);
        }

        Ok(Self {
            host: host.to_owned(),
            port: port.parse::<Port>()?,
        })
    }
}

Implementing FromStr makes "example.com:8080".parse::<Endpoint>() return a Result, so malformed input cannot be mistaken for a valid endpoint.

The ? after port.parse::<Port>() converts a PortError into an EndpointError through the From implementation. The error type records which boundary failed without forcing the parsing function to write a long nested match.

The results are now clear:

  • "example.com:8080" produces an Endpoint;
  • "example.com" produces MissingPort;
  • ":8080" produces EmptyHost;
  • "example.com:0" produces InvalidPort(Zero);
  • "example.com:70000" produces InvalidPort(Invalid).

This parser is intentionally small. Real hostnames, IPv6 addresses, URLs, and service names have more rules. A type should enforce the rules that its domain promises, not pretend that splitting on the last colon is a complete URL parser.

The same boundary idea applies to JSON and other deserializers. A deserializer can construct a validated domain type through a checked conversion instead of leaving raw strings throughout the application. The important part is not the library attribute used to connect the parser; it is that the unchecked representation does not cross the boundary unnoticed.

Choose Option for absence

Rust’s standard library uses an enum for a common choice:

pub enum Option<T> {
    Some(T),
    None,
}

Use Option<T> when a value may be absent. For example, a search that may find no matching number can make that possibility explicit:

fn first_even(numbers: &[i32]) -> Option<i32> {
    numbers.iter().copied().find(|number| number % 2 == 0)
}

The caller must decide what None means. It cannot accidentally treat a special number as a real result. This is safer than using -1, 0, or another sentinel valueIn computer programming, a sentinel value is a special value in the context of an algorithm which uses its presence as a condition of termination, typically in a loop or recursive algorithm.Read on Wikipedia → when that value might otherwise be valid.

The type should describe the distinction that the domain actually needs. An empty Vec<T> usually means that there are no items, so a plain Vec<T> is enough. In some designs, an empty collection and an absent collection have different meanings. Then Option<Vec<T>> can represent both cases:

  • None: no collection was supplied;
  • Some(Vec::new()): a collection was supplied, but it is empty.

Do not add Option merely because a field is sometimes convenient to omit. Decide what absence means. The same question applies to text: if an empty string and missing text mean the same thing, use String; if they mean different things, Option<String> communicates that difference.

Sometimes two nested optional states are technically possible but difficult to read:

Option<Option<String>>

A named enum often explains the domain better:

enum Update<T> {
    Keep,
    Clear,
    Set(T),
}

This is useful for a partial update. Keep leaves a field unchanged, Clear removes its current value, and Set(value) replaces it. The type says what the three states mean instead of making the reader decode the nesting.

Choose Result for failure

An operation that can fail has a different shape from an operation that simply may find nothing. Rust represents that distinction with another enum:

pub enum Result<T, E> {
    Ok(T),
    Err(E),
}

For example, parsing a port number can succeed with a Port or fail with an error. Because Port implements FromStr, the conversion is already shaped as Result<Port, PortError>:

fn parse_port(text: &str) -> Result<Port, PortError> {
    text.parse()
}

parse_port("8080") returns Ok with a valid Port. parse_port("70000") fails because the number does not fit in u16, while parse_port("0") fails the non-zero rule. The caller has to handle the possibility of failure or pass it to another function with ?.

This is different from Option. None answers “was there a value?” Err can explain why an operation did not produce its result. Here Result<T, E> and its Ok/Err variants keep the failure reason visible to the caller.

Use combinators for small transformations

Option and Result have methods that make a short transformation readable without a full match:

fn host_of(line: &str) -> Option<&str> {
    line.rsplit_once(':')
        .map(|(host, _port)| host)
        .filter(|host| !host.is_empty())
}

map changes the value inside Some and leaves None alone. filter turns a value into None when the predicate is false. The function still communicates the possibility of absence in its return type.

Use and_then when the next operation also returns an Option or Result; it avoids creating nested values:

fn numeric_port(line: &str) -> Option<u16> {
    line.rsplit_once(':')
        .and_then(|(_, port)| port.parse::<u16>().ok())
}

For a domain-specific error, keep the failure instead of converting it to None:

fn port_from_line(line: &str) -> Result<Port, EndpointError> {
    line.rsplit_once(':')
        .map(|(_, port)| port)
        .ok_or(EndpointError::MissingPort)?
        .parse::<Port>()
        .map_err(EndpointError::InvalidPort)
}

Use a combinator when the operation is one linear transformation. Use match when the branches have different actions or the meaning would be hidden by a chain of method calls. Use let ... else when one pattern is the successful path and all other cases leave the current scope.

These styles are not competing rules. They are different ways to make the same state handling visible. The best choice is the one that lets a reader see the success case and the failure or absence case without reconstructing a chain of clever expressions.

Let ownership describe state transitions

Enums can own the data associated with a state. That is especially useful when changing state should also move or release a resource:

struct Session {
    id: u64,
}

Here is an ownership-aware version of the earlier state model. Each state keeps the endpoint, and the connected state owns its session:

enum OwnedConnection {
    Disconnected,
    Connecting {
        endpoint: Endpoint,
        attempt: u32,
    },
    Connected {
        endpoint: Endpoint,
        session: Session,
    },
    Failed {
        endpoint: Endpoint,
        reason: String,
    },
}

impl OwnedConnection {
    fn fail(self, reason: String) -> OwnedConnection {
        match self {
            OwnedConnection::Connecting { endpoint, .. }
            | OwnedConnection::Connected { endpoint, .. } => {
                OwnedConnection::Failed { endpoint, reason }
            }
            other => other,
        }
    }
}

fail consumes the old state and returns the new state. If the old state contained a Session, moving it out of the matched variant or allowing it to be dropped is part of the transition. The compiler checks that every branch returns an OwnedConnection.

A consuming method is often the simplest way to model a state change because ownership follows the data. It also prevents another part of the program from using the old value after the transition.

Sometimes an API must update a value through &mut self rather than consume it. You cannot move a field out of *self directly because the borrowed value must remain usable. std::mem::replace temporarily moves the old state out while leaving a valid replacement in its place:

impl OwnedConnection {
    fn retry(&mut self) {
        let old = std::mem::replace(self, OwnedConnection::Disconnected);

        *self = match old {
            OwnedConnection::Connected { endpoint, session } => {
                drop(session);
                OwnedConnection::Connecting {
                    endpoint,
                    attempt: 1,
                }
            }
            other => other,
        };
    }
}

The replacement is useful here because the transition reuses the endpoint from the connected state. The pattern makes the transition explicit: take the old state, release the session, then install the new state.

This approach also explains why duplicating state fields is risky. If an enum already says whether a session exists, adding a second is_connected field creates two sources of truth again.

Runtime state and typestate are different tools

An enum is appropriate when the state is discovered at runtime. A network connection can fail, a file can reach end-of-input, and a user can send an event. The program needs to inspect the current value and branch.

Sometimes the sequence of operations is known from the code itself. A channel might be closed when created, open after a successful handshake, and usable only while open. The typestate pattern represents those states with different Rust types:

use std::marker::PhantomData;

struct Closed;
struct Open;

struct Channel<State> {
    endpoint: Endpoint,
    _state: PhantomData<State>,
}

impl Channel<Closed> {
    fn new(endpoint: Endpoint) -> Self {
        Self {
            endpoint,
            _state: PhantomData,
        }
    }

    fn open(self) -> Channel<Open> {
        Channel {
            endpoint: self.endpoint,
            _state: PhantomData,
        }
    }
}

impl Channel<Open> {
    fn send(&mut self, message: &str) {
        println!("to {}: {message}", self.endpoint.host());
    }

    fn close(self) -> Channel<Closed> {
        Channel {
            endpoint: self.endpoint,
            _state: PhantomData,
        }
    }
}

Channel<Closed> has new and open, but no send method. Channel<Open> has send and close. Calling send before open is rejected at compile time, while opening the channel first makes send available:

fn send_message(text: &str) -> Result<(), EndpointError> {
    let endpoint: Endpoint = "example.com:8080".parse()?;
    let closed = Channel::new(endpoint);
    // closed.send(text); // does not compile: Channel<Closed> has no send method

    let mut channel = closed.open();
    channel.send(text);
    Ok(())
}

PhantomData<State> records the state type without storing a runtime value for it. PhantomData<State> also affects how the compiler reasons about ownership and drop checking, so it is more than a general-purpose marker with no semantics.

A real open operation can fail. Its signature might return Result<Channel<Open>, (Channel<Closed>, OpenError)> if the caller should be able to retry with the original closed channel. Returning the original value is an ownership decision: the error does not have to destroy the resource that could be reused.

Typestate is valuable when the allowed sequence is fixed by the program’s control flow. It is a poor fit when a collection contains values in different runtime states. A Vec<Channel<Open>> cannot also hold Channel<Closed> values without another abstraction. In that situation, an enum is usually clearer.

Typestate also increases the number of types and generic implementations in the API. Use it when preventing an invalid operation is worth that complexity, not simply because the pattern is available.

Design public types for change

Types are also part of a library’s public contract. A public enum exposes its variants to downstream code. Adding a variant to an ordinary public enum can break exhaustive matches in those crates.

A library can mark an enum with #[non_exhaustive] when it needs room to add variants:

#[non_exhaustive]
pub enum ConnectError {
    Refused,
    TimedOut,
}

Code outside the defining crate must include a wildcard arm:

fn describe(error: &ConnectError) -> &'static str {
    match error {
        ConnectError::Refused => "refused",
        ConnectError::TimedOut => "timed out",
        _ => "another connection error",
    }
}

This makes adding a variant less likely to break compilation, but downstream code is no longer forced to notice every new variant. That trade-off is part of the #[non_exhaustive] API rule.

Public structs need similar care. If callers can construct a struct literal with all public fields, adding a field is a breaking change. Private fields and public constructors give the library room to evolve its representation while preserving the operations that callers need.

A constructor is also a useful place to preserve invariants. For example, a Port can keep its NonZeroU16 field private while exposing get, TryFrom, and FromStr. Callers do not need to know how the value is stored, and the library can change the internal representation later.

These choices affect the compatibility promise described by Cargo’s semver guidance. A type that is easy to construct today may be difficult to evolve tomorrow, so public construction syntax is part of the design decision.

What the representation does and does not promise

Strong modeling does not mean that every abstraction has zero runtime or memory cost. An enum may need a discriminant, the value that identifies its active variant, in addition to the largest payload. Alignment and padding can also affect its size.

Rust can sometimes use an invalid bit pattern in a payload to represent another variant. This is why Option<&T> can use the same representation as &T: a null reference is not a valid reference, so it can represent None. The standard library documents specific representation guarantees for Option rather than promising a particular layout for every enum.

These assertions rely on documented guarantees:

use std::mem::size_of;
use std::num::NonZeroU16;

assert_eq!(size_of::<Option<&u8>>(), size_of::<&u8>());
assert_eq!(size_of::<Option<Box<u32>>>(), size_of::<Box<u32>>());
assert_eq!(size_of::<Option<NonZeroU16>>(), size_of::<u16>());

A Port declared with #[repr(transparent)] around NonZeroU16 can also participate in the documented transparent-wrapper guarantee. The standard-library guarantee for Option representations lists the cases that are safe to rely on.

Do not infer that Option<String>, Option<bool>, or an ordinary user-defined enum has a particular size just because one compiler version produces that result. The default representation remains deliberately unspecified in the Rust Reference’s type-layout rules. If a layout is part of an FFI or binary format contract, specify the representation and test the boundary rather than relying on an observation.

The same caution applies to claims about performance. A newtype often disappears after compilation, and PhantomData carries no stored state, but validation still performs work and a larger enum may affect memory use. Type design should first express the correct contract; measure performance when the representation matters.

When a simpler type is better

A type can be too precise for the problem. A private application function that receives a short-lived string may not need a Username newtype. A Boolean is often clearer than a two-variant enum when the domain really is a permanent yes-or-no decision. A runtime enum is simpler than typestate when values move through states based on events.

Extra types have real costs:

  • more constructors and conversions to learn;
  • more names in error messages;
  • more generic parameters when typestate is used;
  • more API decisions when the type is public;
  • possible friction at boundaries that use external libraries.

The practical choice is to put precision where it prevents a realistic mistake. A Port is useful when zero is invalid and ports cross many interfaces. It may be unnecessary for a local loop counter. An enum is useful when branches carry different data. It adds little when a value is simply a Boolean used once.

A good design also avoids encoding the same rule twice. If Port guarantees non-zero values, functions that receive a Port should not repeat the same check. If Connection stores its state as variants, it should not also store a Boolean copy of that state. One invariant should have one clear owner.

Make the boundary carry the rule

The useful design decision usually happens when data enters a part of the program. Parse text into a validated type, turn mutually exclusive cases into enum variants, and keep the representation private when callers should depend only on the invariant.

After that boundary, the rest of the code can use the type’s meaning instead of repeating the same defensive checks. The compiler cannot understand every business rule, but it can check every rule that the types express. That makes important state changes visible in function signatures, pattern matches, and ownership.

<— back to rust