3. The Newtype and Type-State Patterns π‘
What you'll learn:
- The newtype pattern for zero-cost compile-time type safety
- Type-state pattern: making illegal state transitions unrepresentable
- Builder pattern with type states for compile-timeβenforced construction
- Config trait pattern for taming generic parameter explosion
Newtype: Zero-Cost Type Safety
The newtype pattern wraps an existing type in a single-field tuple struct to create a distinct type with zero runtime overhead:
// Without newtypes β easy to mix up:
fn create_user(name: String, email: String, age: u32, employee_id: u32) { }
// create_user(name, email, age, id); β but what if we swap age and id?
// create_user(name, email, id, age); β COMPILES FINE, BUG
// With newtypes β the compiler catches mistakes:
struct UserName(String);
struct Email(String);
struct Age(u32);
struct EmployeeId(u32);
fn create_user(name: UserName, email: Email, age: Age, id: EmployeeId) { }
// create_user(name, email, EmployeeId(42), Age(30));
// β Compile error: expected Age, got EmployeeId
impl Deref for Newtypes β Power and Pitfalls
Implementing Deref on a newtype lets it auto-coerce to the inner type's
reference, giving you all of the inner type's methods "for free":
use std::ops::Deref;
struct Email(String);
impl Email {
fn new(raw: &str) -> Result<Self, &'static str> {
if raw.contains('@') {
Ok(Email(raw.to_string()))
} else {
Err("invalid email: missing @")
}
}
}
impl Deref for Email {
type Target = str;
fn deref(&self) -> &str { &self.0 }
}
// Now Email auto-derefs to &str:
let email = Email::new("user@example.com").unwrap();
println!("Length: {}", email.len()); // Uses str::len via Deref
This is convenient β but it effectively punches a hole through your newtype's abstraction boundary because every method on the target type becomes callable on your wrapper.
When Deref IS appropriate
| Scenario | Example | Why it's fine |
|---|---|---|
| Smart-pointer wrappers | Box<T>, Arc<T>, MutexGuard<T> | The wrapper's whole purpose is to behave like T |
| Transparent "thin" wrappers | String β str, PathBuf β Path, Vec<T> β [T] | The wrapper IS-A superset of the target |
| Your newtype genuinely IS the inner type | struct Hostname(String) where you always want full string ops | Restricting the API would add no value |
When Deref is an anti-pattern
| Scenario | Problem |
|---|---|
| Domain types with invariants | Email derefs to &str, so callers can call .split_at(), .trim(), etc. β none of which preserve the "must contain @" invariant. If someone stores the trimmed &str and reconstructs, the invariant is lost. |
| Types where you want a restricted API | struct Password(String) with Deref<Target = str> leaks .as_bytes(), .chars(), Debug output β exactly what you're trying to hide. |
| Fake inheritance | Using Deref to make ManagerWidget auto-deref to Widget simulates OOP inheritance. This is explicitly discouraged β see the Rust API Guidelines (C-DEREF). |
Rule of thumb: If your newtype exists to add type safety or restrict the API, don't implement
Deref. If it exists to add capabilities while keeping the inner type's full surface (like a smart pointer),Derefis the right choice.
DerefMut β doubles the risk
If you also implement DerefMut, callers can mutate the inner value
directly, bypassing any validation in your constructors:
use std::ops::{Deref, DerefMut};
struct PortNumber(u16);
impl Deref for PortNumber {
type Target = u16;
fn deref(&self) -> &u16 { &self.0 }
}
impl DerefMut for PortNumber {
fn deref_mut(&mut self) -> &mut u16 { &mut self.0 }
}
let mut port = PortNumber(443);
*port = 0; // Bypasses any validation β now an invalid port
Only implement DerefMut when the inner type has no invariants to protect.
Prefer explicit delegation instead
When you want only some of the inner type's methods, delegate explicitly:
struct Email(String);
impl Email {
fn new(raw: &str) -> Result<Self, &'static str> {
if raw.contains('@') { Ok(Email(raw.to_string())) }
else { Err("missing @") }
}
// Expose only what makes sense:
pub fn as_str(&self) -> &str { &self.0 }
pub fn len(&self) -> usize { self.0.len() }
pub fn domain(&self) -> &str {
self.0.split('@').nth(1).unwrap_or("")
}
// .split_at(), .trim(), .replace() β NOT exposed
}
Clippy and the ecosystem
clippy::wrong_self_conventioncan fire whenDerefcoercion makes method resolution surprising (e.g.,is_empty()resolving to the inner type's version instead of one you intended to shadow).- The Rust API Guidelines (C-DEREF) state: "only smart pointers
should implement
Deref." Treat this as a strong default; deviate only with clear justification. - If you need trait compatibility (e.g., passing
Emailto functions expecting&str), consider implementingAsRef<str>andBorrow<str>instead β they're explicit conversions without auto-coercion surprises.
Decision matrix
Do you want ALL methods of the inner type to be callable?
ββ YES β Does your type enforce invariants or restrict the API?
β ββ NO β impl Deref β
(smart-pointer / transparent wrapper)
β ββ YES β Don't impl Deref β (invariant leaks)
ββ NO β Don't impl Deref β (use AsRef / explicit delegation)
Type-State: Compile-Time Protocol Enforcement
The type-state pattern uses the type system to enforce that operations happen in the correct order. Invalid states become unrepresentable.
stateDiagram-v2
[*] --> Disconnected: new()
Disconnected --> Connected: connect()
Connected --> Authenticated: authenticate()
Authenticated --> Authenticated: request()
Authenticated --> [*]: drop
Disconnected --> Disconnected: β request() won't compile
Connected --> Connected: β request() won't compile
Each transition consumes
selfand returns a new type β the compiler enforces valid ordering.
// Problem: A network connection that must be:
// 1. Created
// 2. Connected
// 3. Authenticated
// 4. Then used for requests
// Calling request() before authenticate() should be a COMPILE error.
// --- Type-state markers (zero-sized types) ---
struct Disconnected;
struct Connected;
struct Authenticated;
// --- Connection parameterized by state ---
struct Connection<State> {
address: String,
_state: std::marker::PhantomData<State>,
}
// Only Disconnected connections can connect:
impl Connection<Disconnected> {
fn new(address: &str) -> Self {
Connection {
address: address.to_string(),
_state: std::marker::PhantomData,
}
}
fn connect(self) -> Connection<Connected> {
println!("Connecting to {}...", self.address);
Connection {
address: self.address,
_state: std::marker::PhantomData,
}
}
}
// Only Connected connections can authenticate:
impl Connection<Connected> {
fn authenticate(self, _token: &str) -> Connection<Authenticated> {
println!("Authenticating...");
Connection {
address: self.address,
_state: std::marker::PhantomData,
}
}
}
// Only Authenticated connections can make requests:
impl Connection<Authenticated> {
fn request(&self, path: &str) -> String {
format!("GET {} from {}", path, self.address)
}
}
fn main() {
let conn = Connection::new("api.example.com");
// conn.request("/data"); // β Compile error: no method `request` on Connection<Disconnected>
let conn = conn.connect();
// conn.request("/data"); // β Compile error: no method `request` on Connection<Connected>
let conn = conn.authenticate("secret-token");
let response = conn.request("/data"); // β
Only works after authentication
println!("{response}");
}
Key insight: Each state transition consumes
selfand returns a new type. You can't use the old state after transitioning β the compiler enforces it. Zero runtime cost βPhantomDatais zero-sized, states are erased at compile time.
Comparison with C++/C#: In C++ or C#, you'd enforce this with runtime checks (if (!authenticated) throw ...). The Rust type-state pattern moves these checks to compile time β invalid states are literally unrepresentable in the type system.
Builder Pattern with Type States
A practical application β a builder that enforces required fields:
use std::marker::PhantomData;
// Marker types for required fields
struct NeedsName;
struct NeedsPort;
struct Ready;
struct ServerConfig<State> {
name: Option<String>,
port: Option<u16>,
max_connections: usize, // Optional, has default
_state: PhantomData<State>,
}
impl ServerConfig<NeedsName> {
fn new() -> Self {
ServerConfig {
name: None,
port: None,
max_connections: 100,
_state: PhantomData,
}
}
fn name(self, name: &str) -> ServerConfig<NeedsPort> {
ServerConfig {
name: Some(name.to_string()),
port: self.port,
max_connections: self.max_connections,
_state: PhantomData,
}
}
}
impl ServerConfig<NeedsPort> {
fn port(self, port: u16) -> ServerConfig<Ready> {
ServerConfig {
name: self.name,
port: Some(port),
max_connections: self.max_connections,
_state: PhantomData,
}
}
}
impl ServerConfig<Ready> {
fn max_connections(mut self, n: usize) -> Self {
self.max_connections = n;
self
}
fn build(self) -> Server {
Server {
name: self.name.unwrap(),
port: self.port.unwrap(),
max_connections: self.max_connections,
}
}
}
struct Server {
name: String,
port: u16,
max_connections: usize,
}
fn main() {
// Must provide name, then port, then can build:
let server = ServerConfig::new()
.name("my-server")
.port(8080)
.max_connections(500)
.build();
// ServerConfig::new().port(8080); // β Compile error: no method `port` on NeedsName
// ServerConfig::new().name("x").build(); // β Compile error: no method `build` on NeedsPort
}
Case Study: Type-Safe Connection Pool
Real-world systems need connection pools where connections move through well-defined states. Here's how the typestate pattern enforces correctness in a production pool:
stateDiagram-v2
[*] --> Idle: pool.acquire()
Idle --> Active: conn.begin_transaction()
Active --> Active: conn.execute(query)
Active --> Idle: conn.commit() / conn.rollback()
Idle --> [*]: pool.release(conn)
Active --> [*]: β cannot release mid-transaction
use std::marker::PhantomData;
// States
struct Idle;
struct InTransaction;
struct PooledConnection<State> {
id: u32,
_state: PhantomData<State>,
}
struct Pool {
next_id: u32,
}
impl Pool {
fn new() -> Self { Pool { next_id: 0 } }
fn acquire(&mut self) -> PooledConnection<Idle> {
self.next_id += 1;
println!("[pool] Acquired connection #{}", self.next_id);
PooledConnection { id: self.next_id, _state: PhantomData }
}
// Only idle connections can be released β prevents mid-transaction leaks
fn release(&self, conn: PooledConnection<Idle>) {
println!("[pool] Released connection #{}", conn.id);
}
}
impl PooledConnection<Idle> {
fn begin_transaction(self) -> PooledConnection<InTransaction> {
println!("[conn #{}] BEGIN", self.id);
PooledConnection { id: self.id, _state: PhantomData }
}
}
impl PooledConnection<InTransaction> {
fn execute(&self, query: &str) {
println!("[conn #{}] EXEC: {}", self.id, query);
}
fn commit(self) -> PooledConnection<Idle> {
println!("[conn #{}] COMMIT", self.id);
PooledConnection { id: self.id, _state: PhantomData }
}
fn rollback(self) -> PooledConnection<Idle> {
println!("[conn #{}] ROLLBACK", self.id);
PooledConnection { id: self.id, _state: PhantomData }
}
}
fn main() {
let mut pool = Pool::new();
let conn = pool.acquire();
let conn = conn.begin_transaction();
conn.execute("INSERT INTO users VALUES ('Alice')");
conn.execute("INSERT INTO orders VALUES (1, 42)");
let conn = conn.commit(); // Back to Idle
pool.release(conn); // β
Only works on Idle connections
// pool.release(conn_active); // β Compile error: can't release InTransaction
}
Why this matters in production: A connection leaked mid-transaction holds database locks indefinitely. The typestate pattern makes this impossible β you literally cannot return a connection to the pool until the transaction is committed or rolled back.
Config Trait Pattern β Taming Generic Parameter Explosion
The Problem
As a struct takes on more responsibilities, each backed by a trait-constrained generic, the type signature grows unwieldy:
trait SpiBus { fn spi_transfer(&self, tx: &[u8], rx: &mut [u8]) -> Result<(), BusError>; }
trait ComPort { fn com_send(&self, data: &[u8]) -> Result<usize, BusError>; }
trait I3cBus { fn i3c_read(&self, addr: u8, buf: &mut [u8]) -> Result<(), BusError>; }
trait SmBus { fn smbus_read_byte(&self, addr: u8, cmd: u8) -> Result<u8, BusError>; }
trait GpioBus { fn gpio_set(&self, pin: u32, high: bool); }
// β Every new bus trait adds another generic parameter
struct DiagController<S: SpiBus, C: ComPort, I: I3cBus, M: SmBus, G: GpioBus> {
spi: S,
com: C,
i3c: I,
smbus: M,
gpio: G,
}
// impl blocks, function signatures, and callers all repeat the full list.
// Adding a 6th bus means editing every mention of DiagController<S, C, I, M, G>.
This is often called "generic parameter explosion." It compounds across impl blocks,
function parameters, and downstream consumers β each of which must repeat the full
parameter list.
The Solution: A Config Trait
Bundle all associated types into a single trait. The struct then has one generic parameter regardless of how many component types it contains:
#[derive(Debug)]
enum BusError {
Timeout,
NakReceived,
HardwareFault(String),
}
// --- Bus traits (unchanged) ---
trait SpiBus {
fn spi_transfer(&self, tx: &[u8], rx: &mut [u8]) -> Result<(), BusError>;
fn spi_write(&self, data: &[u8]) -> Result<(), BusError>;
}
trait ComPort {
fn com_send(&self, data: &[u8]) -> Result<usize, BusError>;
fn com_recv(&self, buf: &mut [u8], timeout_ms: u32) -> Result<usize, BusError>;
}
trait I3cBus {
fn i3c_read(&self, addr: u8, buf: &mut [u8]) -> Result<(), BusError>;
fn i3c_write(&self, addr: u8, data: &[u8]) -> Result<(), BusError>;
}
// --- The Config trait: one associated type per component ---
trait BoardConfig {
type Spi: SpiBus;
type Com: ComPort;
type I3c: I3cBus;
}
// --- DiagController has exactly ONE generic parameter ---
struct DiagController<Cfg: BoardConfig> {
spi: Cfg::Spi,
com: Cfg::Com,
i3c: Cfg::I3c,
}
DiagController<Cfg> will never gain another generic parameter.
Adding a 4th bus means adding one associated type to BoardConfig and one field
to DiagController β no downstream signature changes.
Implementing the Controller
impl<Cfg: BoardConfig> DiagController<Cfg> {
fn new(spi: Cfg::Spi, com: Cfg::Com, i3c: Cfg::I3c) -> Self {
DiagController { spi, com, i3c }
}
fn read_flash_id(&self) -> Result<u32, BusError> {
let cmd = [0x9F]; // JEDEC Read ID
let mut id = [0u8; 4];
self.spi.spi_transfer(&cmd, &mut id)?;
Ok(u32::from_be_bytes(id))
}
fn send_bmc_command(&self, cmd: &[u8]) -> Result<Vec<u8>, BusError> {
self.com.com_send(cmd)?;
let mut resp = vec![0u8; 256];
let n = self.com.com_recv(&mut resp, 1000)?;
resp.truncate(n);
Ok(resp)
}
fn read_sensor_temp(&self, sensor_addr: u8) -> Result<i16, BusError> {
let mut buf = [0u8; 2];
self.i3c.i3c_read(sensor_addr, &mut buf)?;
Ok(i16::from_be_bytes(buf))
}
fn run_full_diag(&self) -> Result<DiagReport, BusError> {
let flash_id = self.read_flash_id()?;
let bmc_resp = self.send_bmc_command(b"VERSION\n")?;
let cpu_temp = self.read_sensor_temp(0x48)?;
let gpu_temp = self.read_sensor_temp(0x49)?;
Ok(DiagReport {
flash_id,
bmc_version: String::from_utf8_lossy(&bmc_resp).to_string(),
cpu_temp_c: cpu_temp,
gpu_temp_c: gpu_temp,
})
}
}
#[derive(Debug)]
struct DiagReport {
flash_id: u32,
bmc_version: String,
cpu_temp_c: i16,
gpu_temp_c: i16,
}
Production Wiring
One impl BoardConfig selects the concrete hardware drivers:
struct PlatformSpi { dev: String, speed_hz: u32 }
struct UartCom { dev: String, baud: u32 }
struct LinuxI3c { dev: String }
impl SpiBus for PlatformSpi {
fn spi_transfer(&self, tx: &[u8], rx: &mut [u8]) -> Result<(), BusError> {
// ioctl(SPI_IOC_MESSAGE) in production
rx[0..4].copy_from_slice(&[0xEF, 0x40, 0x18, 0x00]);
Ok(())
}
fn spi_write(&self, _data: &[u8]) -> Result<(), BusError> { Ok(()) }
}
impl ComPort for UartCom {
fn com_send(&self, _data: &[u8]) -> Result<usize, BusError> { Ok(0) }
fn com_recv(&self, buf: &mut [u8], _timeout: u32) -> Result<usize, BusError> {
let resp = b"BMC v2.4.1\n";
buf[..resp.len()].copy_from_slice(resp);
Ok(resp.len())
}
}
impl I3cBus for LinuxI3c {
fn i3c_read(&self, _addr: u8, buf: &mut [u8]) -> Result<(), BusError> {
buf[0] = 0x00; buf[1] = 0x2D; // 45Β°C
Ok(())
}
fn i3c_write(&self, _addr: u8, _data: &[u8]) -> Result<(), BusError> { Ok(()) }
}
// β
One struct, one impl β all concrete types resolved here
struct ProductionBoard;
impl BoardConfig for ProductionBoard {
type Spi = PlatformSpi;
type Com = UartCom;
type I3c = LinuxI3c;
}
fn main() {
let ctrl = DiagController::<ProductionBoard>::new(
PlatformSpi { dev: "/dev/spidev0.0".into(), speed_hz: 10_000_000 },
UartCom { dev: "/dev/ttyS0".into(), baud: 115200 },
LinuxI3c { dev: "/dev/i3c-0".into() },
);
let report = ctrl.run_full_diag().unwrap();
println!("{report:#?}");
}
Test Wiring with Mocks
Swap the entire hardware layer by defining a different BoardConfig:
struct MockSpi { flash_id: [u8; 4] }
struct MockCom { response: Vec<u8> }
struct MockI3c { temps: std::collections::HashMap<u8, i16> }
impl SpiBus for MockSpi {
fn spi_transfer(&self, _tx: &[u8], rx: &mut [u8]) -> Result<(), BusError> {
rx[..4].copy_from_slice(&self.flash_id);
Ok(())
}
fn spi_write(&self, _data: &[u8]) -> Result<(), BusError> { Ok(()) }
}
impl ComPort for MockCom {
fn com_send(&self, _data: &[u8]) -> Result<usize, BusError> { Ok(0) }
fn com_recv(&self, buf: &mut [u8], _timeout: u32) -> Result<usize, BusError> {
let n = self.response.len().min(buf.len());
buf[..n].copy_from_slice(&self.response[..n]);
Ok(n)
}
}
impl I3cBus for MockI3c {
fn i3c_read(&self, addr: u8, buf: &mut [u8]) -> Result<(), BusError> {
let temp = self.temps.get(&addr).copied().unwrap_or(0);
buf[..2].copy_from_slice(&temp.to_be_bytes());
Ok(())
}
fn i3c_write(&self, _addr: u8, _data: &[u8]) -> Result<(), BusError> { Ok(()) }
}
struct TestBoard;
impl BoardConfig for TestBoard {
type Spi = MockSpi;
type Com = MockCom;
type I3c = MockI3c;
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_controller() -> DiagController<TestBoard> {
let mut temps = std::collections::HashMap::new();
temps.insert(0x48, 45i16);
temps.insert(0x49, 72i16);
DiagController::<TestBoard>::new(
MockSpi { flash_id: [0xEF, 0x40, 0x18, 0x00] },
MockCom { response: b"BMC v2.4.1\n".to_vec() },
MockI3c { temps },
)
}
#[test]
fn test_flash_id() {
let ctrl = make_test_controller();
assert_eq!(ctrl.read_flash_id().unwrap(), 0xEF401800);
}
#[test]
fn test_sensor_temps() {
let ctrl = make_test_controller();
assert_eq!(ctrl.read_sensor_temp(0x48).unwrap(), 45);
assert_eq!(ctrl.read_sensor_temp(0x49).unwrap(), 72);
}
#[test]
fn test_full_diag() {
let ctrl = make_test_controller();
let report = ctrl.run_full_diag().unwrap();
assert_eq!(report.flash_id, 0xEF401800);
assert_eq!(report.cpu_temp_c, 45);
assert_eq!(report.gpu_temp_c, 72);
assert!(report.bmc_version.contains("2.4.1"));
}
}
Adding a New Bus Later
When you need a 4th bus, only two things change β BoardConfig and DiagController.
No downstream signature changes. The generic parameter count stays at one:
trait SmBus {
fn smbus_read_byte(&self, addr: u8, cmd: u8) -> Result<u8, BusError>;
}
// 1. Add one associated type:
trait BoardConfig {
type Spi: SpiBus;
type Com: ComPort;
type I3c: I3cBus;
type Smb: SmBus; // β new
}
// 2. Add one field:
struct DiagController<Cfg: BoardConfig> {
spi: Cfg::Spi,
com: Cfg::Com,
i3c: Cfg::I3c,
smb: Cfg::Smb, // β new
}
// 3. Provide the concrete type in each config impl:
impl BoardConfig for ProductionBoard {
type Spi = PlatformSpi;
type Com = UartCom;
type I3c = LinuxI3c;
type Smb = LinuxSmbus; // β new
}
When to Use This Pattern
| Situation | Use Config Trait? | Alternative |
|---|---|---|
| 3+ trait-constrained generics on a struct | β Yes | β |
| Need to swap entire hardware/platform layer | β Yes | β |
| Only 1-2 generics | β Overkill | Direct generics |
| Need runtime polymorphism | β | dyn Trait objects |
| Open-ended plugin system | β | Type-map / Any |
| Component traits form a natural group (board, platform) | β Yes | β |
Key Properties
- One generic parameter forever β
DiagController<Cfg>never gains more<A, B, C, ...> - Fully static dispatch β no vtables, no
dyn, no heap allocation for trait objects - Clean test swapping β define
TestBoardwith mock impls, zero conditional compilation - Compile-time safety β forget an associated type β compile error, not runtime crash
- Battle-tested β this is the pattern used by Substrate/Polkadot's frame system
to manage 20+ associated types through a single
Configtrait
Key Takeaways β Newtype & Type-State
- Newtypes give compile-time type safety at zero runtime cost
- Type-state makes illegal state transitions a compile error, not a runtime bug
- Config traits tame generic parameter explosion in large systems
See also: Ch 4 β PhantomData for the zero-sized markers that power type-state. Ch 2 β Traits In Depth for associated types used in the config trait pattern.
Case Study: Dual-Axis Typestate β Vendor Γ Protocol State
The patterns above handle one axis at a time: typestate enforces protocol order,
and trait abstraction handles multiple vendors. Real systems often need both
simultaneously: a wrapper Handle<Vendor, State> where available methods depend
on which vendor is plugged in and which state the handle is in.
This section shows the dual-axis conditional impl pattern β where impl
blocks are gated on both a vendor trait bound and a state marker trait.
The Two-Dimensional Problem
Consider a debug probe interface (JTAG/SWD). Multiple vendors make probes, and every probe must be unlocked before registers become accessible. Some vendors additionally support direct memory reads β but only after an extended unlock that configures the memory access port:
graph LR
subgraph "All vendors"
L["π Locked"] -- "unlock()" --> U["π Unlocked"]
end
subgraph "Memory-capable vendors only"
U -- "extended_unlock()" --> E["ππ§ ExtendedUnlocked"]
end
U -. "read_reg() / write_reg()" .-> U
E -. "read_reg() / write_reg()" .-> E
E -. "read_memory() / write_memory()" .-> E
style L fill:#fee,stroke:#c33
style U fill:#efe,stroke:#3a3
style E fill:#eef,stroke:#33c
The capability matrix β which methods exist for which (vendor, state) combination β is two-dimensional:
block-beta
columns 4
space header1["Locked"] header2["Unlocked"] header3["ExtendedUnlocked"]
basic["Basic Vendor"]:1 b1["unlock()"] b2["read_reg()\nwrite_reg()"] b3["β unreachable β"]
memory["Memory Vendor"]:1 m1["unlock()"] m2["read_reg()\nwrite_reg()\nextended_unlock()"] m3["read_reg()\nwrite_reg()\nread_memory()\nwrite_memory()"]
style b1 fill:#ffd,stroke:#aa0
style b2 fill:#efe,stroke:#3a3
style b3 fill:#eee,stroke:#999,stroke-dasharray: 5 5
style m1 fill:#ffd,stroke:#aa0
style m2 fill:#efe,stroke:#3a3
style m3 fill:#eef,stroke:#33c
The challenge: express this matrix entirely at compile time, with static
dispatch, so that calling extended_unlock() on a basic probe or
read_memory() on an unlocked-but-not-extended handle is a compile error.
The Solution: Jtag<V, S> with Marker Traits
Step 1 β State tokens and capability markers:
use std::marker::PhantomData;
// Zero-sized state tokens β no runtime cost
struct Locked;
struct Unlocked;
struct ExtendedUnlocked;
// Marker traits express which capabilities each state has
trait HasRegAccess {}
impl HasRegAccess for Unlocked {}
impl HasRegAccess for ExtendedUnlocked {}
trait HasMemAccess {}
impl HasMemAccess for ExtendedUnlocked {}
Why marker traits, not just concrete states? Writing
impl<V, S: HasRegAccess> Jtag<V, S>meansread_reg()works in any state with register access β today that'sUnlockedandExtendedUnlocked, but if you addDebugHaltedtomorrow, you just add one line:impl HasRegAccess for DebugHalted {}. Every register function works with it automatically β zero code changes.
Step 2 β Vendor traits (raw operations):
// Every probe vendor implements these
trait JtagVendor {
fn raw_unlock(&mut self);
fn raw_read_reg(&self, addr: u32) -> u32;
fn raw_write_reg(&mut self, addr: u32, val: u32);
}
// Vendors with memory access also implement this super-trait
trait JtagMemoryVendor: JtagVendor {
fn raw_extended_unlock(&mut self);
fn raw_read_memory(&self, addr: u64, buf: &mut [u8]);
fn raw_write_memory(&mut self, addr: u64, data: &[u8]);
}
Step 3 β The wrapper with conditional impl blocks:
struct Jtag<V, S = Locked> {
vendor: V,
_state: PhantomData<S>,
}
// Construction β always starts Locked
impl<V: JtagVendor> Jtag<V, Locked> {
fn new(vendor: V) -> Self {
Jtag { vendor, _state: PhantomData }
}
fn unlock(mut self) -> Jtag<V, Unlocked> {
self.vendor.raw_unlock();
Jtag { vendor: self.vendor, _state: PhantomData }
}
}
// Register I/O β any vendor, any state with HasRegAccess
impl<V: JtagVendor, S: HasRegAccess> Jtag<V, S> {
fn read_reg(&self, addr: u32) -> u32 {
self.vendor.raw_read_reg(addr)
}
fn write_reg(&mut self, addr: u32, val: u32) {
self.vendor.raw_write_reg(addr, val);
}
}
// Extended unlock β only memory-capable vendors, only from Unlocked
impl<V: JtagMemoryVendor> Jtag<V, Unlocked> {
fn extended_unlock(mut self) -> Jtag<V, ExtendedUnlocked> {
self.vendor.raw_extended_unlock();
Jtag { vendor: self.vendor, _state: PhantomData }
}
}
// Memory I/O β only memory-capable vendors, only ExtendedUnlocked
impl<V: JtagMemoryVendor, S: HasMemAccess> Jtag<V, S> {
fn read_memory(&self, addr: u64, buf: &mut [u8]) {
self.vendor.raw_read_memory(addr, buf);
}
fn write_memory(&mut self, addr: u64, data: &[u8]) {
self.vendor.raw_write_memory(addr, data);
}
}
Each impl block encodes one cell (or row) of the capability matrix.
The compiler enforces the matrix β no runtime checks anywhere.
Vendor Implementations
Adding a vendor means implementing raw methods on one struct β no per-state struct duplication, no delegation boilerplate:
// Vendor A: basic probe β register access only
struct BasicProbe { port: u16 }
impl JtagVendor for BasicProbe {
fn raw_unlock(&mut self) { /* TAP reset sequence */ }
fn raw_read_reg(&self, addr: u32) -> u32 { /* DR scan */ 0 }
fn raw_write_reg(&mut self, addr: u32, val: u32) { /* DR scan */ }
}
// BasicProbe does NOT impl JtagMemoryVendor.
// extended_unlock() will not compile on Jtag<BasicProbe, _>.
// Vendor B: full-featured probe β registers + memory
struct DapProbe { serial: String }
impl JtagVendor for DapProbe {
fn raw_unlock(&mut self) { /* SWD switch, read DPIDR */ }
fn raw_read_reg(&self, addr: u32) -> u32 { /* AP register read */ 0 }
fn raw_write_reg(&mut self, addr: u32, val: u32) { /* AP register write */ }
}
impl JtagMemoryVendor for DapProbe {
fn raw_extended_unlock(&mut self) { /* select MEM-AP, power up */ }
fn raw_read_memory(&self, addr: u64, buf: &mut [u8]) { /* MEM-AP read */ }
fn raw_write_memory(&mut self, addr: u64, data: &[u8]) { /* MEM-AP write */ }
}
What the Compiler Prevents
| Attempt | Error | Why |
|---|---|---|
Jtag<_, Locked>::read_reg() | no method read_reg | Locked doesn't impl HasRegAccess |
Jtag<BasicProbe, _>::extended_unlock() | no method extended_unlock | BasicProbe doesn't impl JtagMemoryVendor |
Jtag<_, Unlocked>::read_memory() | no method read_memory | Unlocked doesn't impl HasMemAccess |
Calling unlock() twice | value used after move | unlock() consumes self |
All four errors are caught at compile time. No panics, no Option, no runtime state enum.
Writing Generic Functions
Functions bind only the axes they care about:
/// Works with ANY vendor, ANY state that grants register access.
fn read_idcode<V: JtagVendor, S: HasRegAccess>(jtag: &Jtag<V, S>) -> u32 {
jtag.read_reg(0x00)
}
/// Only compiles for memory-capable vendors in ExtendedUnlocked state.
fn dump_firmware<V: JtagMemoryVendor, S: HasMemAccess>(jtag: &Jtag<V, S>) {
let mut buf = [0u8; 256];
jtag.read_memory(0x0800_0000, &mut buf);
}
read_idcode doesn't care whether you're in Unlocked or ExtendedUnlocked β
it only requires HasRegAccess. This is where marker traits pay off over
hardcoding specific states in signatures.
Same Pattern, Different Domain: Storage Backends
The dual-axis technique isn't hardware-specific. Here's the same structure for a storage layer where some backends support transactions:
// States
struct Closed;
struct Open;
struct InTransaction;
trait HasReadWrite {}
impl HasReadWrite for Open {}
impl HasReadWrite for InTransaction {}
// Vendor traits
trait StorageBackend {
fn raw_open(&mut self);
fn raw_read(&self, key: &[u8]) -> Option<Vec<u8>>;
fn raw_write(&mut self, key: &[u8], value: &[u8]);
}
trait TransactionalBackend: StorageBackend {
fn raw_begin(&mut self);
fn raw_commit(&mut self);
fn raw_rollback(&mut self);
}
// Wrapper
struct Store<B, S = Closed> { backend: B, _s: PhantomData<S> }
impl<B: StorageBackend> Store<B, Closed> {
fn open(mut self) -> Store<B, Open> { self.backend.raw_open(); /* ... */ todo!() }
}
impl<B: StorageBackend, S: HasReadWrite> Store<B, S> {
fn read(&self, key: &[u8]) -> Option<Vec<u8>> { self.backend.raw_read(key) }
fn write(&mut self, key: &[u8], val: &[u8]) { self.backend.raw_write(key, val) }
}
impl<B: TransactionalBackend> Store<B, Open> {
fn begin(mut self) -> Store<B, InTransaction> { /* ... */ todo!() }
}
impl<B: TransactionalBackend> Store<B, InTransaction> {
fn commit(mut self) -> Store<B, Open> { /* ... */ todo!() }
fn rollback(mut self) -> Store<B, Open> { /* ... */ todo!() }
}
A flat-file backend implements StorageBackend only β begin() won't
compile. A database backend adds TransactionalBackend β the full
Open β InTransaction β Open cycle becomes available.
When to Reach for This Pattern
| Signal | Why dual-axis fits |
|---|---|
| Two independent axes: "who provides it" and "what state is it in" | The impl block matrix directly encodes both |
| Some providers have strictly more capabilities than others | Super-trait (MemoryVendor: Vendor) + conditional impl |
| Misusing state or capability is a safety/correctness bug | Compile-time prevention > runtime checks |
| You want static dispatch (no vtables) | PhantomData + generics = zero-cost |
| Signal | Consider something simpler |
|---|---|
| Only one axis varies (state OR vendor, not both) | Single-axis typestate or plain trait objects |
| Three or more independent axes | Config Trait Pattern (above) bundles axes into associated types |
| Runtime polymorphism is acceptable | enum state + dyn dispatch is simpler |
When two axes become three or more: If you find yourself writing
Handle<V, S, D, T>β vendor, state, debug level, transport β the generic parameter list is telling you something. Consider collapsing the vendor axis into an associated-type config trait (the Config Trait Pattern from earlier in this chapter), keeping only the state axis as a generic parameter:Handle<Cfg, S>. The config trait bundlestype Vendor,type Transport, etc. into one parameter, and the state axis retains its compile-time transition guarantees. This is a natural evolution, not a rewrite β you lift vendor-related types intoCfgand leave the typestate machinery untouched.
Key Takeaway: The dual-axis pattern is the intersection of typestate and trait-based abstraction. Each
implblock maps to one cell of the (vendor Γ state) matrix. The compiler enforces the entire matrix β no runtime state checks, no impossible-state panics, no cost.
Exercise: Type-Safe State Machine β β (~30 min)
Build a traffic light state machine using the type-state pattern. The light must transition Red β Green β Yellow β Red and no other order should be possible.
use std::marker::PhantomData;
struct Red;
struct Green;
struct Yellow;
struct TrafficLight<State> {
_state: PhantomData<State>,
}
impl TrafficLight<Red> {
fn new() -> Self {
println!("π΄ Red β STOP");
TrafficLight { _state: PhantomData }
}
fn go(self) -> TrafficLight<Green> {
println!("π’ Green β GO");
TrafficLight { _state: PhantomData }
}
}
impl TrafficLight<Green> {
fn caution(self) -> TrafficLight<Yellow> {
println!("π‘ Yellow β CAUTION");
TrafficLight { _state: PhantomData }
}
}
impl TrafficLight<Yellow> {
fn stop(self) -> TrafficLight<Red> {
println!("π΄ Red β STOP");
TrafficLight { _state: PhantomData }
}
}
fn main() {
let light = TrafficLight::new(); // Red
let light = light.go(); // Green
let light = light.caution(); // Yellow
let _light = light.stop(); // Red
// light.caution(); // β Compile error: no method `caution` on Red
// TrafficLight::new().stop(); // β Compile error: no method `stop` on Red
}
Key takeaway: Invalid transitions are compile errors, not runtime panics.
</details>