Rust Patterns That Leverage the Type System
NewType, parse don't validate, TypeState, and sealed traits, four Rust patterns that make invalid states impossible to compile.
The patterns in this deep-dive represent well-established techniques from type theory and functional programming, adapted to work with Rust's unique ownership and borrowing system. While these patterns have origins in languages such as Haskell and MetaLanguage (ML), as well as research on type systems, Rust brings its own contributions: zero-cost abstractions, compile-time enforcement through ownership, and integration with systems programming.
What makes these patterns particularly valuable in Rust isn’t their novelty. Most have decades of history in other languages. It is how Rust’s design makes them practical for systems programming. The combination of strong static typing, zero-cost abstractions, and memory safety without garbage collection creates opportunities to apply these patterns in contexts where they were previously impractical.
We’ll apply these patterns to Samsa, a publish/subscribe microservice in the style of a miniature Kafka that the book builds across its later chapters, patterns that have proven their worth across multiple programming language communities. As we explore each pattern, we’ll examine both its origins and how Rust’s unique features enhance or constrain its application.
This deep-dive is excerpted from Design Patterns and Best Practices in Rust by Evan Williams, shared with permission from Packt, with all rights remaining with the publisher and no reproduction or redistribution without written consent. You can get the full book here.
In this chapter the book covers the following main topics:
The NewType pattern
Parse, don’t validate
The TypeState pattern
Sealed traits
Each pattern builds on the previous ones: the NewType pattern creates distinct types; parse, don’t validate ensures those types hold only valid data; the TypeState pattern tracks valid state transitions; and sealed traits control which types can participate in our APIs.
Technical requirements
The source code for the exercises can be found on GitHub at https://github.com/PacktPublishing/Design-Patterns-and-Best-Practices-in-Rust. The repository is organized by chapter. The relevant exercises for this chapter are at https://github.com/PacktPublishing/Design-Patterns-and-Best-Practices-in-Rust/tree/main/ch10.
The NewType pattern
The NewType pattern has a long history in statically-typed functional programming, particularly in Haskell, where the newtype keyword has been a language feature since the 1990s. The pattern also appears in ML, OCaml, and other languages with strong type systems. The core idea of wrapping existing types in distinct types for semantic clarity and type safety predates Rust. It is a response to the issue that values that are conceptually different, such as temperature and weight, have the same type representation in the code, for example, a float. From the compiler’s perspective, they are the same, and that leads to bugs when these values are inadvertently mixed up.
What Rust brings to this well-established pattern is zero-cost abstraction: the wrapped type compiles to exactly the same representation as the underlying type, with no runtime overhead. In languages with garbage collection or boxing, creating wrapper types often incurs performance costs. Rust’s design ensures that type safety is free at runtime while remaining enforceable at compile time.
Identifying the problem
In our Samsa system, we currently use primitive types such as u64 for topic IDs, consumer IDs, and message offsets. This approach, which we will call primitive obsession, makes it easy to accidentally pass the wrong type of ID to a function. If you’ve worked with similar systems, you’ve probably experienced the confusion this creates. The NewType pattern solves this problem with compile-time type checking.
Let’s examine our current API and identify where primitive obsession creates issues:
// Current API - prone to mix-ups
impl Broker {
pub fn subscribe(&self, topic_id: u64, consumer_id: u64) -> Result<()> {
// Easy to accidentally swap these parameters!
if !self.topics.contains_key(&topic_id) {
return Err("Topic not found".to_string());
}
Ok(())
}
}
impl Consumer {
pub fn new(broker: Arc<Broker>, topic_id: u64, offset: u64) -> Self {
// Again, easy to mix up topic_id and offset
Self { broker, topic_id, offset }
}
}We’re using raw u64 values for different semantic concepts, making it easy to pass the wrong type of ID and causing runtime bugs that are difficult to debug. What is very unfortunate is that the compiler cannot help us identify logical errors when we pass a topic ID instead of a consumer ID.
Implementing type-safe wrappers
Let’s create wrapper types that eliminate these mix-ups. Our approach has three parts:
Define semantic wrapper types for our different ID concepts (shown in the first code block)
Implement basic methods for usability, such as constructors and accessors (shown in the second code block)
Add
Displayimplementations for user-friendly output (shown in the third code block)
Let’s define semantic wrapper types: TopicId for topic identification, ConsumerId for tracking individual consumers, and MessageId for tracking individual messages. We’ll use plain u64 for message offsets for simplicity (in the current design, message offsets don’t benefit much from the additional type safety):
use std::fmt;
/// Topic identifier for tracking individual topics
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TopicId(String);
/// Consumer identifier for tracking individual consumers
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ConsumerId(String);
/// Message identifier for tracking individual messages
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct MessageId(u64);These NewTypes provide type safety through distinct wrapper types. TopicId and ConsumerId wrap String to allow meaningful identifiers such as "user.events" or "consumer-1", while MessageId wraps u64 for numeric message tracking. Each type has meaningful derives, such as Hash for use in collections. Note that MessageId includes Copy since u64 is copyable, while the String-based types do not.
Now, let’s add methods for creating and accessing these types:
impl TopicId {
pub fn new(topic: impl AsRef<str>) -> Result<Self> {
let topic = topic.as_ref();
if topic.is_empty() {
return Err(SamsaError::topic("Topic name cannot be empty"));
}
if topic.len() > 128 {
return Err(SamsaError::topic(
format!("Topic name too long: {} chars (max 128)",
topic.len())
));
}
Ok(TopicId(topic.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl ConsumerId {
pub fn new(id: impl AsRef<str>) -> Result<Self> {
let id = id.as_ref();
if id.is_empty() {
return Err(SamsaError::consumer("Consumer ID cannot be
empty"));
}
Ok(ConsumerId(id.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl MessageId {
pub fn new(id: u64) -> Self {
MessageId(id)
}
pub fn value(&self) -> u64 {
self.0
}
}Each type provides a new constructor and an accessor method. TopicId and ConsumerId validate their inputs at construction time, returning Result to signal potential validation failures. This is the NewType pattern combined with early validation: once you have TopicId or ConsumerId, you know it contains valid data. MessageId uses simple u64 wrapping since numeric IDs don’t require validation.
Finally, let’s add Display implementations for user-friendly output:
impl fmt::Display for TopicId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl fmt::Display for ConsumerId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl fmt::Display for MessageId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}The Display implementations output the inner value directly. For example, a TopicId containing "user.events" prints as user.events. If we were writing production code, we could also implement a custom Debug trait and print the IDs in a way that is more helpful for debugging, such as "TopicID(user.events)".
We’ve created distinct types for different domain concepts. Each type wraps its inner value and prevents accidental mixing. It’s now impossible to accidentally pass a TopicId where a ConsumerId is expected. The compiler catches this at compile time, eliminating entire classes of bugs. Each wrapper type is distinct at compile time.
For MessageId wrapping u64, the type compiles to the same representation as u64 at runtime, creating a true zero-cost abstraction. TopicId and ConsumerId use String for semantic identifiers but still provide compile-time type safety.
Enhanced Samsa API
Let’s see how NewTypes prevent parameter mix-ups. Consider a subscription function that takes both a consumer and a topic:
fn subscribe(consumer: ConsumerId, topic: TopicId) -> Result<(), SamsaError> {
println!("Subscribing {} to {}", consumer, topic);
Ok(())
}
// Usage - this compiles:
let topic = TopicId::new("orders.created")?;
let consumer = ConsumerId::new("analytics-service")?;
subscribe(consumer, topic)?;
// But this won't compile - arguments swapped:
// subscribe(topic, consumer);
// ^^^^^ expected `ConsumerId`, found `TopicId`Notice how subscribe takes TopicId and ConsumerId as separate types. The compiler would reject any attempt to swap them. Without NewTypes, both topic and consumer would be String, and swapping them would compile silently – a bug you’d only discover at runtime (maybe while in production). With NewTypes, the compiler catches the mistake immediately. This is especially valuable in APIs with multiple string-like parameters where mix-ups are easy to make and hard to debug.
Now that we have our type-safe wrappers, let’s apply them to Samsa’s API to see how they prevent the parameter mix-ups we identified earlier. The following code snippet enhances the consumer API with type safety:
pub struct Consumer {
broker: Arc<Broker>,
id: ConsumerId,
topic: TopicId,
offset: u64,
}
impl Consumer {
pub fn new(broker: Arc<Broker>, id: ConsumerId, topic: TopicId,
offset: u64) -> Self {
Self { broker, id, topic, offset }
}
pub fn poll(&mut self) -> Result<Option<Message>, String> {
let events = self.broker.fetch(self.topic, self.offset, 1)?;
if let Some(message) = events.into_iter().next() {
self.offset += 1;
Ok(Some(message))
} else {
Ok(None)
}
}
}The Consumer struct stores typed identifiers for its broker connection, ID, topic, and current offset. The poll() method demonstrates how advancement works with the typed consumer. Each field uses the appropriate NewType, making the struct’s purpose clear from its type signature alone.
Our entire API now uses type-safe identifiers. Function signatures are self-documenting, and the compiler prevents mixing up different types of IDs. This eliminates bugs where IDs are confused, provides better error messages, and makes the code more maintainable. With this design, Samsa’s consumer API becomes self-documenting and resistant to ID mix-ups.
The NewType pattern provides compile-time guarantees about type correctness while maintaining the runtime performance of primitive types. This combination of safety and efficiency is what makes the pattern particularly valuable in Rust.
However, while NewTypes give us distinct types, they don’t enforce validity. A MessageId could wrap any u64, which is OK if every u64 is a valid message ID. But if some are not, including those invalid values in our data type can turn into an issue. In the next section, we’ll see how to combine type distinctions with validity guarantees using the “parse, don’t validate” principle.
Parse, don’t validate
Alexis King popularized the parse, don’t validate principle with her influential 2019 blog post of the same name (https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/). King’s articulation of the principle, which states that parsing generates types that can represent only valid data, while validation merely checks data after the fact, has its roots in functional programming and type theory and helped popularize and name the pattern.
Rust’s type system makes this principle particularly natural to apply. The combination of strong typing, pattern matching, and the Result type creates an environment where parse, don’t validate feels like the path of least resistance rather than additional work.
The principle suggests that instead of scattering validation logic throughout a code base, we should parse data once at system boundaries into types that can only represent valid states. This transforms validation from a repeated burden into a guarantee encoded in types.
The problem with scattered validation
Let’s examine how our current Samsa configuration system handles validation. The current approach is validation scattered everywhere:
pub struct BrokerConfig {
pub max_connections: i32, // Could be negative!
pub buffer_size: usize, // Could be zero!
pub port: u16, // Could be in reserved range!
}
impl Broker {
pub fn new(config: BrokerConfig) -> Result<Self, String> {
// Validation #1 - at construction
if config.max_connections <= 0 {
return Err("max_connections must be positive".to_string());
}
if config.buffer_size == 0 {
return Err("buffer_size must be non-zero".to_string());
}
Ok(Self { config })
}
pub fn accept_connection(&self) -> Result<(), String> {
// Validation #2 - in business logic (defensive programming)
if self.config.max_connections <= 0 {
return Err("Invalid max_connections".to_string());
}
Ok(())
}
}Notice how the same validation check, if max_connections <= 0, appears in two different places: once during configuration creation and once defensively in the business logic. This duplication is the core problem. Each location must remember to perform the check, use the same condition, and handle errors consistently. If the validation rule changes (say, max_connections must be at least 10), every location must be updated. This approach is error-prone, inefficient, and creates inconsistent validation logic across different parts of the system.
Creating types that enforce validity
The solution is to create wrapper types that can only hold valid values. Instead of validating a raw u32 everywhere it’s used, we create a PositiveU32 type that validates once at construction time. Any code that receives a PositiveU32 type knows, by the type alone, that the value has already been validated.
Let’s redesign our configuration using this approach. We’ll start by creating a wrapper type for positive integers that cannot be zero or negative:
use std::num::NonZeroUsize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PositiveU32(u32);
impl PositiveU32 {
pub fn new(value: u32) -> Result<Self, ConfigError> {
if value > 0 {
Ok(Self(value))
} else {
Err(ConfigError::MustBePositive("value".to_string()))
}
}
pub fn get(self) -> u32 {
self.0
}
}Next, we create a similar wrapper for network ports that ensures the port number is not in the reserved range (ports 0–1023):
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NetworkPort(u16);
impl NetworkPort {
pub fn new(port: u16) -> Result<Self, ConfigError> {
if port > 1023 { // Ports 0-1023 are reserved
Ok(Self(port))
} else {
Err(ConfigError::PortInReservedRange(port))
}
}
pub fn get(self) -> u16 {
self.0
}
}Let’s also define an error type for configuration errors:
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigError {
MustBePositive(String),
PortInReservedRange(u16),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigError::MustBePositive(field) => write!(f, "{} must be positive", field),
ConfigError::PortInReservedRange(port) => write!(f, "Port {} is in reserved range", port),
}
}
}
impl std::error::Error for ConfigError {}Samsa’s configuration system now uses types that enforce validity at construction time. Once created, these types guarantee their validity throughout the system. This moves all validation to the boundary of our system and eliminates defensive programming in business logic.
By validating once during construction, we ensure that these types can only ever hold valid values. Rust’s ownership system guarantees that once created, these values cannot be modified to become invalid.
Builder pattern with validation
The Builder pattern pairs naturally with validated types. While the wrapper types we defined validate individual values, the Builder pattern coordinates multiple validated values into a complete, consistent configuration. We’ll create a BrokerConfig that can only be constructed through a builder, ensuring that all validation happens during construction.
First, let’s define the configuration struct with validated field types:
#[derive(Debug, Clone)]
pub struct BrokerConfig {
max_connections: PositiveU32,
buffer_size: NonZeroUsize,
port: NetworkPort,
}
impl BrokerConfig {
pub fn builder() -> BrokerConfigBuilder {
BrokerConfigBuilder::new()
}
// Getters that never need validation
pub fn max_connections(&self) -> u32 {
self.max_connections.get()
}
pub fn buffer_size(&self) -> usize {
self.buffer_size.get()
}
pub fn port(&self) -> u16 {
self.port.get()
}
}The BrokerConfig struct uses validated types for all fields. PositiveU32 and NetworkPort ensure that invalid values cannot be stored. The getters return primitive values, which is safe because we know the wrapped values are valid. Notice that all fields are private and accessed through getters. This prevents external code from modifying the configuration after validation.
Now, we need a way for users to construct a BrokerConfig. Since the struct has private fields with validated types, we can’t allow direct construction. Users would need access to PositiveU32::new() and NetworkPort::new(), and they’d have to handle validation errors themselves. Instead, we’ll provide a builder that accepts raw primitive values, validates them internally, and produces a fully validated BrokerConfig on success.
The BrokerConfigBuilder struct holds Option fields for each configuration value. As users set values through the builder’s methods, each value is validated and wrapped in its corresponding type. The final build() method ensures that all required fields are present before constructing the BrokerConfig. Let’s implement this builder:
pub struct BrokerConfigBuilder {
max_connections: Option<PositiveU32>,
buffer_size: Option<NonZeroUsize>,
port: Option<NetworkPort>,
}
impl BrokerConfigBuilder {
pub fn new() -> Self {
Self {
max_connections: None,
buffer_size: None,
port: None,
}
}
pub fn max_connections(mut self, value: u32) -> Result<Self, ConfigError> {
self.max_connections = Some(PositiveU32::new(value)?);
Ok(self)
}
pub fn buffer_size(mut self, value: usize) -> Result<Self, ConfigError> {
self.buffer_size = Some(
NonZeroUsize::new(value)
.ok_or_else(|| ConfigError::MustBePositive("buffer_size".to_string()))?
);
Ok(self)
}
pub fn port(mut self, value: u16) -> Result<Self, ConfigError> {
self.port = Some(NetworkPort::new(value)?);
Ok(self)
}
}Each setter method validates its input before storing it. The ? operator propagates validation errors immediately if validation fails. If validation succeeds, the method consumes self and returns it, enabling method chaining. The use of Option for each field lets us detect missing fields in the build() method.
The final step is building the configuration, which ensures that all required fields are present and results in a fully validated BrokerConfig:
impl BrokerConfigBuilder {
pub fn build(self) -> Result<BrokerConfig, ConfigError> {
Ok(BrokerConfig {
max_connections: self.max_connections
.ok_or_else(|| ConfigError::MustBePositive("max_connections not set".to_string()))?,
buffer_size: self.buffer_size
.ok_or_else(|| ConfigError::MustBePositive("buffer_size not set".to_string()))?,
port: self.port
.ok_or_else(|| ConfigError::PortInReservedRange(0))?,
})
}
}The build() method consumes the builder and returns Result<BrokerConfig, ConfigError>. If any required field is missing, we return an error. This ensures that you can’t construct an incomplete configuration.
The usage now looks like this:
let config = BrokerConfig::builder()
.max_connections(1000)?
.buffer_size(4096)?
.port(8080)?
.build()?;The fluent API makes configuration construction readable and catches errors immediately. If any setter returns an error, the ? operator short-circuits, and the invalid configuration is never created. The final build() call ensures that no required fields are missing.
The builder ensures that all configuration values are validated during construction. Once a BrokerConfig exists, all its values are guaranteed to be valid. This design makes it impossible to create invalid configurations, with validation happening once at the system boundary.
The parse, don’t validate principle, combined with Rust’s type system and ownership model, eliminates defensive programming throughout the system by moving all validation to well-defined boundaries.
So far, we’ve seen how to create distinct types (NewType) and ensure that those types only hold valid values (parse, don’t validate). But what about objects that change over time? In the next section, we’ll explore the TypeState pattern, which uses the type system to track and enforce valid state transitions.
The TypeState pattern
The TypeState pattern has academic origins in research on program verification. It was formalized by Robert E. Strom and Shaula Yemini in their 1986 paper TypeState: A Programming Language Concept for Enhancing Software Reliability (which you can read at https://www.computer.org/csdl/journal/ts/1986/01/06312929/13rRUwIF6aQ). The core idea is to use types to represent different states in which an object can be, making invalid state transitions impossible to express.
While the concept has existed in type theory for decades, Rust’s unique combination of features makes it particularly practical to implement. Rust’s zero-cost abstractions mean the type-level state tracking compiles away entirely, leaving no runtime overhead. The ownership system ensures that state transitions consume the old state, preventing accidental reuse. And generic types with PhantomData provide a clean mechanism for encoding state in the type system.
Several other languages support similar patterns, most notably session types in research languages and builder patterns in languages such as Java, but Rust’s combination of performance, safety, and ergonomics makes the TypeState pattern particularly accessible for systems programming.
The problem with runtime state management
Let’s examine traditional runtime state management, where state is tracked at runtime:
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ConsumerState {
Disconnected,
Connected,
Subscribed,
Paused,
}
pub struct Consumer {
broker: Arc<Broker>,
topic: TopicId,
consumer_id: ConsumerId,
state: ConsumerState,
}
impl Consumer {
pub fn connect(&mut self) -> Result<(), String> {
// Runtime check required
if self.state != ConsumerState::Disconnected {
return Err("Consumer must be disconnected to connect".to_string());
}
self.state = ConsumerState::Connected;
Ok(())
}
pub fn poll(&mut self) -> Result<Option<Message>, String> {
// Runtime validation in every method
if self.state != ConsumerState::Subscribed {
return Err("Consumer must be subscribed to poll
messages".to_string());
}
Ok(None)
}
}Every method requires runtime validation to ensure a correct state, creating opportunities for errors and adding runtime overhead. The compiler can’t help us by catching state transition errors, which leads to runtime failures that could have been prevented at compile time.
Implementing TypeState for the consumer lifecycle
Let’s redesign the consumer using TypeState. We’ll create separate types for each state:
use std::marker::PhantomData;
use crate::types::*;
// State marker types organized in a module
pub mod states {
#[derive(Debug)]
pub struct Disconnected;
#[derive(Debug)]
pub struct Connected;
#[derive(Debug)]
pub struct Subscribed;
#[derive(Debug)]
pub struct Paused;
}
// Consumer parameterized by state
pub struct Consumer<State> {
broker: Arc<Broker>,
topic: Option<TopicId>,
consumer_id: ConsumerId,
connection_info: Option<ConnectionInfo>,
state: PhantomData<State>,
}
/// Type aliases for consumer states
pub type DisconnectedConsumer = Consumer<states::Disconnected>;
pub type ConnectedConsumer = Consumer<states::Connected>;
pub type SubscribedConsumer = Consumer<states::Subscribed>;
pub type PausedConsumer = Consumer<states::Paused>;
#[derive(Debug, Clone)]
pub struct ConnectionInfo {
pub broker_address: String,
pub consumer_group: Option<String>,
}The state marker types (Disconnected, Connected, Subscribed, and Paused) are zero-sized. They exist only at compile time to track state in the type system. The Consumer<State> struct is generic over the state marker, so Consumer<states::Disconnected> and Consumer<states::Connected> are different types even though they contain the same data fields. The type aliases provide convenient shorthand.
The PhantomData<State> field tells Rust about the state parameter without storing anything at runtime. This is the key to TypeState: state tracked in types with zero runtime cost.
Let’s implement the disconnected state:
impl Consumer<states::Disconnected> {
pub fn new(consumer_id: ConsumerId, broker: Arc<Broker>) -> Self {
Self {
broker,
topic: None,
consumer_id,
connection_info: None,
state: PhantomData,
}
}
pub fn connect(
self,
connection_info: ConnectionInfo
) -> Result<Consumer<states::Connected>, SamsaError> {
if connection_info.broker_address.is_empty() {
return Err(SamsaError::connection("Empty broker address"));
}
Ok(Consumer {
broker: self.broker,
topic: self.topic,
consumer_id: self.consumer_id,
connection_info: Some(connection_info),
state: PhantomData,
})
}
}The new constructor creates a Consumer<states::Disconnected>. The connect method consumes the disconnected consumer (taking ownership with self) and returns either a Consumer<states::Connected> or an error along with the original consumer. This ownership transfer prevents reuse. Once you call connect, the disconnected consumer is gone.
Now, let’s implement the connected state:
impl Consumer<states::Connected> {
pub fn subscribe(
mut self,
topic: TopicId
) -> Result<Consumer<states::Subscribed>, SamsaError> {
self.topic = Some(topic);
Ok(Consumer {
broker: self.broker,
topic: self.topic,
consumer_id: self.consumer_id,
connection_info: self.connection_info,
state: PhantomData,
})
}
pub fn disconnect(self) -> Consumer<states::Disconnected> {
Consumer {
broker: self.broker,
topic: None,
consumer_id: self.consumer_id,
connection_info: None,
state: PhantomData,
}
}
}Each state transition follows the same pattern: consume the current state, perform an operation, and return the new state or an error. The connected consumer can subscribe to a topic or disconnect. Each transition is a separate impl block, making it impossible to call methods from the wrong state.
Finally, the Subscribed state is where consumers can actually receive messages, while the Paused state can only resume:
impl Consumer<states::Subscribed> {
pub fn receive(&self) -> Option<Event> {
// Only subscribed consumers can receive - guaranteed by types!
None
}
pub fn pause(self) -> Consumer<states::Paused> {
Consumer {
broker: self.broker,
topic: self.topic,
consumer_id: self.consumer_id,
connection_info: self.connection_info,
state: PhantomData,
}
}
pub fn unsubscribe(mut self) -> Consumer<states::Connected> {
self.topic = None;
Consumer {
broker: self.broker,
topic: self.topic,
consumer_id: self.consumer_id,
connection_info: self.connection_info,
state: PhantomData,
}
}
}
impl Consumer<states::Paused> {
pub fn resume(self) -> Consumer<states::Subscribed> {
Consumer {
broker: self.broker,
topic: self.topic,
consumer_id: self.consumer_id,
connection_info: self.connection_info,
state: PhantomData,
}
}
}Only Consumer<states::Subscribed> has a receive method. You literally cannot call receive() on consumers in other states. The method doesn’t exist for those types. The Paused state shows that TypeState can model bidirectional transitions: pause() moves to Paused, and resume() returns to Subscribed.
This TypeState implementation makes invalid operations impossible to express. The compiler enforces the correct state machine at compile time with no runtime overhead.
TypeState’s implementation in Rust uses PhantomData to carry type-level information that exists only at compile time, employs generic types to parameterize over state, and utilizes the ownership system to ensure that state transitions consume the previous state. The result is verification of state machines at compile time with zero runtime cost.
Usage examples
Here’s how TypeState improves the developer experience:
fn correct_usage() -> Result<(), SamsaError> {
let broker = Arc::new(Broker::new());
let consumer_id = ConsumerId::new("test-consumer")?;
let topic = TopicId::new("events")?;
// Create disconnected consumer
let consumer = Consumer::new(consumer_id, broker);
// Connect with connection info
let connection_info = ConnectionInfo::new(
"localhost:9092".to_string(), None
);
let connected = consumer.connect(connection_info)?;
// Subscribe to topic
let subscribed = connected.subscribe(topic)?;
// Can pause and resume
let paused = subscribed.pause();
let subscribed = paused.resume();
// Now we can receive - guaranteed to be in correct state
if let Some(event) = subscribed.receive() {
println!("Received: {:?}", event);
}
// Unsubscribe and disconnect
let connected = subscribed.unsubscribe();
let _disconnected = connected.disconnect();
Ok(())
}This code demonstrates the correct sequence: create a disconnected consumer, connect it, subscribe, then receive – pausing and unpausing, unsubscribing, and disconnecting work the same way. Each state transition is explicit in the code, and the types change at each step. The error handling uses a tuple to return the original consumer on failure, allowing retry logic.
Invalid usage won’t compile:
fn invalid_usage() {
let broker = Arc::new(Broker::new());
let consumer_id = ConsumerId::new("test").unwrap();
let consumer = Consumer::new(consumer_id, broker);
// These lines won't compile:
// consumer.receive(); // Error: not available on Disconnected
// consumer.subscribe(topic); // Error: not available on Disconnected
}The compiler errors shown in the comments aren’t runtime errors; they’re compile-time errors. You literally cannot write code that calls receive() on a disconnected consumer because the method doesn’t exist for that type. This is the power of TypeState: invalid states are unrepresentable.
The TypeState pattern transforms potential runtime errors into compile-time guarantees, demonstrating how Rust’s type system and ownership model enable practical implementation of ideas from formal methods and programming language research.
TypeState controls how individual objects transition through states. But sometimes, we need to control which types can participate in a system at all. In the next section, we’ll see how sealed traits let us define closed sets of implementations, providing API stability and safety guarantees.
Sealed traits
Many object-oriented languages introduce the concept of sealed or final types, which are types that cannot extend beyond a defined scope. Java has final classes, Kotlin has sealed classes, and Scala has sealed traits. The pattern addresses a real problem in API design: sometimes, allowing arbitrary external implementations of an interface creates more problems than it solves.
Rust doesn’t have a built-in sealed keyword, but the module system provides a way to achieve the same effect. Following its appearance in various standard library crates and its documentation in API design discussions, the technique gained widespread recognition in the Rust community. While not unique to Rust, the pattern fits naturally with Rust’s privacy model and module system.
The sealed traits pattern uses Rust’s module privacy to create traits that are visible for use but restricted for implementation. This provides library authors with control over trait implementations while maintaining a public interface.
The problem with open traits
Let’s understand why we might want to seal traits. Because this is an open trait, anyone can implement it:
pub trait MessageSchema {
type Data;
type Error: std::error::Error;
fn parse(data: &[u8]) -> Result<Self::Data, Self::Error>;
fn serialize(data: &Self::Data) -> Vec<u8>;
fn validate(data: &Self::Data) -> bool;
}Here, we’ve defined a very straightforward trait defining the signatures of three methods, with associated types for the data payload and the error type. There is nothing unique about this trait, but what we should pay attention to is that this is a public trait, so there are no restrictions on who can implement it or where the implementation happens.
External crates could implement unsafe versions:
// struct UnsafeSchema;
// impl MessageSchema for UnsafeSchema {
// fn parse(_data: &[u8]) -> Result<Self::Data, Self::Error> {
// unsafe { /* potentially unsafe implementation */ }
// }
// }The MessageSchema trait defines an interface that any type can implement. While this flexibility is often desirable, it means we can’t make guarantees about all implementations. External code could implement the trait incorrectly or in ways that violate our assumptions.
When external crates can implement MessageSchema, we lose control over quality guarantees and make it difficult to evolve the trait interface without breaking external implementations.
External implementations might do the following:
Skip validation or implement it incorrectly
Use
unsafecode in unexpected waysDepend on undocumented behavior that we later change
Additionally, evolving the trait interface becomes difficult. Adding a new required method would break all external implementations, forcing us to use default implementations even when that’s not ideal.
Implementing the sealed traits pattern
Let’s implement the sealed traits pattern for our message schema system, using a private module containing the sealing trait:
mod private {
pub trait Sealed {}
}
// Public trait that extends the sealed trait
pub trait MessageSchema: private::Sealed {
type Message: Clone + std::fmt::Debug;
fn serialize(message: &Self::Message) -> Vec<u8>;
fn deserialize(bytes: &[u8]) -> Result<Self::Message, SchemaError>;
fn schema_id() -> &'static str;
fn validate(message: &Self::Message) -> Result<(), ValidationError>;
}The key to the sealed traits pattern is the private module. The Sealed trait is public within the module, but the module itself is private. External crates can see MessageSchema (which extends private::Sealed) but cannot implement Sealed, which means they cannot implement MessageSchema.
This gives us complete control over which types can implement our schema trait.
Let’s implement a JSON schema:
pub struct JsonSchema;
impl private::Sealed for JsonSchema {}
impl MessageSchema for JsonSchema {
type Message = serde_json::Value;
fn serialize(message: &Self::Message) -> Vec<u8> {
serde_json::to_vec(message).unwrap_or_default()
}
fn deserialize(bytes: &[u8]) -> Result<Self::Message, SchemaError> {
serde_json::from_slice(bytes)
.map_err(|e| SchemaError::DeserializationFailed(e.to_string()))
}
fn schema_id() -> &'static str {
"json_v1"
}
fn validate(message: &Self::Message) -> Result<(), ValidationError> {
if message.is_null() {
return Err(ValidationError::InvalidValue(
"Message cannot be null".to_string()
));
}
Ok(())
}
}JsonSchema implements both private::Sealed (which we can do because we’re in the same module) and MessageSchema. The implementation uses serde_json for parsing and serialization and validates that JSON values aren’t null.
Let’s add a text schema for plain String data:
pub struct TextSchema;
impl private::Sealed for TextSchema {}
impl MessageSchema for TextSchema {
type Message = String;
fn serialize(message: &Self::Message) -> Vec<u8> {
message.as_bytes().to_vec()
}
fn deserialize(bytes: &[u8]) -> Result<Self::Message, SchemaError> {
String::from_utf8(bytes.to_vec())
.map_err(|e| SchemaError::DeserializationFailed(e.to_string()))
}
fn schema_id() -> &'static str {
"text_v1"
}
fn validate(message: &Self::Message) -> Result<(), ValidationError> {
if message.is_empty() {
return Err(ValidationError::FieldRequired(
"message content".to_string()
));
}
Ok(())
}
}TextSchema handles plain UTF-8 text. It deserializes byte slices into strings and validates that they aren’t empty. Both schema implementations are within our module, so both can implement private::Sealed.
External crates can use JsonSchema and TextSchema, but cannot create their own schema implementations; the sealed traits pattern prevents it.
The sealed traits pattern leverages Rust’s module privacy: private::Sealed is public within the module, but the sealed module itself is private. External crates can see and use MessageSchema, but cannot implement Sealed, which means they cannot implement MessageSchema.
Type-safe messages with sealed schemas
Now, we can combine sealed schemas with the type-safe message patterns we’ve developed. TypedMessage will be generic over the schema type, ensuring that messages and their schemas are always compatible. The sealed traits pattern prevents external code from creating incompatible schema implementations:
use std::marker::PhantomData;
use crate::schema::MessageSchema;
use crate::types::*;
#[derive(Debug, Clone)]
pub struct TypedMessage<S: MessageSchema> {
pub id: MessageId,
pub content: S::Message,
pub schema_type: PhantomData<S>,
}
impl<S: MessageSchema> TypedMessage<S> {
pub fn new(id: MessageId, content: S::Message) -> Result<Self, ValidationError> {
S::validate(&content)?;
Ok(TypedMessage {
id,
content,
schema_type: PhantomData,
})
}
pub fn to_bytes(&self) -> Vec<u8> {
S::serialize(&self.content)
}
pub fn schema_id(&self) -> &'static str {
S::schema_id()
}
}TypedMessage is generic over the S schema type, where S implements MessageSchema. The content field has the S::Message type, which is the associated type we defined in the message schema trait for the type of our message payload. When we wrote our trait implementations, we defined concrete types for MessageSchema::Message: serde_json::Value for TypedMessage<JsonSchema> and String for TypedMessage<TextSchema>. The schema_type field uses PhantomData to track the schema type at compile time without storing anything at runtime. The new constructor validates the content using the schema before creating the message.
We can extend TypedMessage with additional methods for parsing raw data. The following shows how you might add a parse method (this extension is not in the base repository, but demonstrates the pattern):
impl<S: MessageSchema> TypedMessage<S> {
pub fn parse(
id: MessageId,
raw_data: &[u8],
) -> Result<Self, S::Error> {
let content = S::deserialize(raw_data)?;
Ok(Self {
id,
content,
schema_type: PhantomData,
})
}
pub fn serialize(&self) -> Vec<u8> {
S::serialize(&self.content)
}
}
pub type JsonMessage = TypedMessage<JsonSchema>;
pub type TextMessage = TypedMessage<TextSchema>;The extended parse method takes raw bytes and uses the schema’s deserializer to construct typed content. If parsing fails, the error type is S::Error, which is serde_json::Error for JSON or std::str::Utf8Error for text. The serialize method converts the content back into bytes. This demonstrates how you can build on the sealed trait foundation to add richer functionality.
The type aliases provide convenient names: JsonMessage and TextMessage are clearer than writing TypedMessage<JsonSchema> everywhere. These messages are both type-safe (wrong schema = compile error) and sealed (can’t add new schemas externally).
Thus, the sealed traits pattern provides controlled extensibility. We maintain the ability to evolve our trait interface while providing compile-time guarantees about which implementations exist.
Summary
In this deep-dive, we explored four patterns that leverage Rust’s type system, each with established origins in programming language research and practice.
The NewType pattern comes from functional programming languages, particularly Haskell, where it has been a formal language feature since the 1990s. Rust’s contribution is making the pattern zero-cost: type safety is enforced at compile time and optimized away entirely at runtime, making it practical for systems programming, where performance matters.
Parse, don’t validate was articulated by Alexis King in 2019, building on ideas from functional programming and type theory. Rust’s type system, ownership model, and Result type make this principle natural to apply, turning boundary validation into type-level guarantees that persist throughout the program.
The TypeState pattern originated in formal methods research, specifically in Strom and Yemini’s 1986 work on program verification. Rust’s generic types with PhantomData, zero-cost abstractions, and ownership system make this academic concept practical for everyday systems programming, providing compile-time state machine verification with no runtime overhead.
Sealed traits draw from similar concepts in Java (final classes), Kotlin (sealed classes), and Scala (sealed traits). Rust implements the pattern through its module privacy system, providing a natural fit with the language’s existing visibility rules.
What these patterns demonstrate is Rust’s ability to take established ideas from type theory, functional programming, and formal methods and make them practical for systems programming. The combination of strong static typing, zero-cost abstractions, and memory safety without garbage collection creates opportunities to apply patterns that were previously either too expensive or impractical in systems languages.
Our enhanced Samsa system now leverages decades of programming language research while maintaining the performance characteristics necessary for systems programming. This synthesis, applying proven patterns in a performant, safe environment, represents one of Rust’s key contributions to the programming language space.
The book’s next chapter explores patterns from functional programming, including function composition pipelines, generics as type classes, pattern matching techniques, and closures as architectural components.
More from Evan Williams
Deep Engineering #47: Evan Williams on Why Experienced Developers Have the Hardest Time Learning Rust
Eval Driven Development for Engineers
Design Patterns, Ownership Models, and Building Resilient Systems in Rust with Evan Williams
Evan Williams has been writing software for more than 40 years, across every layer of the stack and more programming languages than most engineers will encounter in a career. His book, Design Patterns and Best Practices in Rust, published by Packt, is not a pattern catalogue. It is an argument for a different way of thinking about code entirely, aimed s…
Rust Is Hard for the Engineers with the Most Experience
Rust, who would have thought, has ranked as the most loved programming language in the Stack Overflow developer survey for nine consecutive years. Honestly, I must admit this is an unusual kind of statistic because it measures not just adoption but retention. The engineers who use Rust want to keep using it, and that pattern has only deepened even as th…
This deep-dive is Chapter 10 of Design Patterns and Best Practices in Rust by Evan Williams, published by Packt and reproduced here in full with the publisher’s permission for knowledge sharing with the Deep Engineering community. The book charts a transformation across three hands-on projects, a deliberately broken calculator that shows what goes wrong when you write Java or C++ in Rust syntax, a rebuild that adapts the classic Gang of Four patterns to the ownership model, and Samsa, the publish/subscribe microservice these pages extend with patterns unique to the language. All rights remain with Packt Publishing, and this content may not be reproduced, redistributed, or remixed in any form without the publisher’s written consent. We also sat down with Evan Williams to talk about the thinking behind these patterns, including why he finds typestate almost impossible to stop talking about, read the conversation here. You can get the full book here.




