Types, Traits,
and the Error Model
Rust has two fundamental ways to compose types: structs (product types) and enums (sum types). The distinction comes from algebra and understanding it makes design decisions feel obvious rather than arbitrary.
A struct is a product type — the total values possible equals the product of each field's range. A struct with a u8 speed field (256 values) and a bool direction field (2 values) has 256 × 2 = 512 possible states. An enum is a sum type — the total equals the sum of the variants. An enum with Forward and Reverse has exactly 2 possible states. You can be in one or the other, never both.
Make illegal states unrepresentable.
In your motor control system, a motor can be stopped, running forward at some speed, or running in reverse at some speed. If you use a struct with separate fields for running, speed, and direction, nothing prevents: running=false, speed=75, direction=forward — a meaningless state. An enum eliminates it: the Stopped variant carries no speed because speed is meaningless when stopped. The Forward(u8) variant carries a speed because speed only exists in the context of movement. The illegal combination cannot be constructed — it has no type representation.
This principle applies equally to your NOC API: alert severity should be an enum, not a String. A site status should be an enum. A payment status, a user role, a protocol state — whenever a field has a fixed set of valid values, an enum is the right type. The compiler becomes a validator that runs on every build.
// Bad — illegal states are possible struct MotorBad { running: bool, speed: u8, // meaningless when running=false forward: bool, // meaningless when running=false } // Good — illegal states cannot be constructed #[derive(Debug, Clone, Copy, PartialEq)] enum MotorState { Stopped, Forward(u8), // speed 0–100, only meaningful when moving Reverse(u8), // speed 0–100, only meaningful when moving Braking, // active braking — distinct from stopped } // Pattern matching is exhaustive — adding a new variant forces the match to be updated fn describe(state: MotorState) -> &'static str { match state { MotorState::Stopped => "stopped", MotorState::Forward(s) => if s > 80 { "fast forward" } else { "forward" }, MotorState::Reverse(s) => if s > 80 { "fast reverse" } else { "reverse" }, MotorState::Braking => "braking", } } // For the NOC API — severity as an enum, not a String #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::Type)] #[sqlx(type_name = "text")] pub enum Severity { Critical, High, Warning, Info } // The database and Rust type now share the same constraint set.
Tony Hoare called the null reference his "billion-dollar mistake" — fifty years of crashes, security vulnerabilities, and production outages caused by forgotten null checks. Rust has no null. Instead it has Option<T>: an enum with two variants, Some(T) and None. The difference from null is structural: Option<T> is a type, not a sentinel value. You cannot use an Option<T> where a T is expected. The compiler refuses. You must handle both cases — not as a runtime discipline but as a compile-time requirement.
fn find_site(sites: &[Site], name: &str) -> Option<&Site> { sites.iter().find(|s| s.name == name) } // 1. match — explicit, handles both cases match find_site(&sites, "DSM Core") { Some(site) => update(site), None => log_missing(), } // 2. if let — when you only care about Some if let Some(site) = find_site(&sites, "Kariakoo") { process(site); } // 3. unwrap_or — provide a default let site = find_site(&sites, "Nakawa").unwrap_or(&fallback); // 4. map — transform Some value, pass None through let name: Option<&str> = find_site(&sites, "Bugolobi").map(|s| s.name.as_str()); // 5. and_then — chain operations that might fail (flatMap) let alert = find_site(&sites, "DSM").and_then(|s| latest_alert(s)); // 6. ok_or — convert Option to Result (essential for ? in handlers) let site = find_site(&sites, "missing") .ok_or(AppError::NotFound)?; // None → Err → early return
Some sensors fail intermittently. Option makes this explicit.
Your DHT11 temperature sensor on the black breakout board sometimes fails due to checksum errors or timing violations. A function reading it should return Option<TemperatureReading>. The caller is forced to decide: retry, use the last valid reading, display dashes on the TM1637, or trigger an alert. The decision is explicit in the code. You cannot accidentally display a garbage temperature because you forgot to check for a sensor failure — the compiler will not let you use an Option where a TemperatureReading is expected.
Python, Java, and most scripting languages use exceptions for error handling — a special control flow mechanism that can propagate invisibly up the call stack. This creates a fundamental dishonesty: a Python function declaring -> str might actually raise FileNotFoundError, PermissionError, or NetworkError — none of which appear in the type signature. The caller must know from documentation what to catch. If they forget, an exception propagates silently until it crashes something or disappears into a bare except Exception.
Rust has no exceptions. Errors are values of type Result<T, E> — an enum with Ok(T) for success and Err(E) for failure. The error type appears in the function signature. Every caller sees what can go wrong. Every caller must handle it. There are no invisible error paths.
// An honest function — the signature shows it can fail fn write_i2c_register(i2c: &mut I2c, reg: u8, val: u8) -> Result<(), I2cError> { i2c.blocking_write(0x40, &[reg, val]) } // 1. match — explicit handling match write_i2c_register(&mut i2c, 0x00, 0x10) { Ok(()) => defmt::info!("register set"), Err(e) => defmt::error!("write failed: {:?}", e), } // 2. map_err — convert one error type to another write_i2c_register(&mut i2c, 0x00, 0x10) .map_err(DriverError::I2c)?; // 3. ok_or — Option → Result for use with ? let site = sites.get(idx).ok_or(AppError::NotFound)?; // 4. Collecting a Vec of Results — fail on first error let readings: Result<Vec<_>, _> = sensors.iter() .map(|s| s.read()) .collect(); // All succeed → Ok(Vec). First failure → Err(that_error). // 5. and_then — chain fallible operations write_i2c_register(&mut i2c, MODE1, SLEEP) .and_then(|_| write_i2c_register(&mut i2c, PRE_SCALE, 121)) .and_then(|_| write_i2c_register(&mut i2c, MODE1, RESTART))?;
The ? operator is syntactic sugar for the early-return-on-error pattern. If the Result is Ok(value), unwrap it and continue. If it is Err(e), call From::from(e) to convert the error type if necessary, and return early. The conversion via From is what makes different error types compatible — if your function returns Result<_, AppError> and AppError implements From<SqlxError>, you can use ? on sqlx queries directly.
// Without ? — the same logic, explicitly fn init_verbose(i2c: &mut I2c) -> Result<(), DriverError> { match write_i2c_register(i2c, MODE1, SLEEP) { Ok(v) => v, Err(e) => return Err(DriverError::from(e)), } match write_i2c_register(i2c, PRE_SCALE, 121) { Ok(v) => v, Err(e) => return Err(DriverError::from(e)), } Ok(()) } // With ? — identical semantics, dramatically cleaner fn init(i2c: &mut I2c) -> Result<(), DriverError> { write_i2c_register(i2c, MODE1, SLEEP)?; write_i2c_register(i2c, PRE_SCALE, 121)?; write_i2c_register(i2c, MODE1, RESTART)?; Ok(()) } // In an Axum handler — ? works identically on the web async fn get_alert( State(s): State<AppState>, Path(id): Path<Uuid>, ) -> Result<Json<Alert>, AppError> { let alert = sqlx::query_as!(Alert, "SELECT * FROM alerts WHERE id = $1", id) .fetch_optional(&s.db) .await? // sqlx::Error → AppError::Database via From .ok_or(AppError::NotFound)?; // None → AppError::NotFound Ok(Json(alert)) }
Rust has no class inheritance. No base classes, no virtual methods, no abstract classes. Instead: traits. A trait is a named collection of method signatures that a type can implement. It is closer to a Go interface or a Java interface than anything class-based, but with two important enhancements: traits can have default implementations for methods, and traits can have associated types and constants.
The embedded-hal project is the best demonstration of why traits matter for hardware. The embedded_hal::i2c::I2c trait defines the methods any I2C bus must provide. A driver written against this trait — like the PCA9685 driver in Chapter 9 — works with the RP2350's I2C peripheral, the STM32's I2C peripheral, or a bit-banged software I2C, without modification. One driver, every microcontroller that implements the trait.
// Define a trait for any 4-digit display pub trait NumberDisplay { /// Required — each display implements this differently fn show_segments(&mut self, position: u8, segments: u8); fn set_brightness(&mut self, level: u8); /// Default implementation — free for all implementors fn show_number(&mut self, n: u16) { const SEGS: [u8;10] = [0x3F,0x06,0x5B,0x4F,0x66,0x6D,0x7D,0x07,0x7F,0x6F]; for i in 0..4 { let digit = ((n / [1000,100,10,1][i]) as usize) % 10; self.show_segments(i as u8, SEGS[digit]); } } } // TM1637 implements NumberDisplay impl<'d> NumberDisplay for Tm1637<'d> { fn show_segments(&mut self, pos: u8, segs: u8) { /* bit-bang implementation */ } fn set_brightness(&mut self, l: u8) { /* brightness command */ } // show_number() is inherited from the default implementation — no code needed } // A function that works with ANY NumberDisplay — resolved at compile time fn show_speed(display: &mut impl NumberDisplay, speed: u8) { display.show_number(speed as u16); } // show_speed(&mut tm1637, 75) compiles to a direct call — zero overhead. // The compiler generates a concrete version for each type used. // This is called monomorphisation — the opposite of runtime dispatch.
When a function argument is impl Trait, the concrete type is known at compile time and the compiler generates a dedicated version of the function — no runtime overhead. When it is dyn Trait, the concrete type is resolved at runtime through a vtable (a table of function pointers) — a small but real overhead, and heap allocation is usually required. In embedded code, always prefer impl Trait. In web code, dyn Trait in a Box<dyn Trait> is appropriate when you need to store different concrete types in the same collection.
Generics allow functions and types to work over multiple concrete types with the specific type resolved at compile time. The generic function fn clamp<T: PartialOrd>(v: T, min: T, max: T) -> T works for u8, u16, f32, or any ordered type. The compiler generates a separate concrete version for each type you actually use — clamp_u8, clamp_f32 — each as fast as if you had written them manually. This is monomorphisation: generics with zero runtime cost.
The T: PartialOrd part is a trait bound — it constrains what types T can be. Without it, the body could not use < and > because not all types support comparison. Trait bounds express, in the type signature, exactly what capabilities a generic type must provide. They are the generics system's contract enforcement mechanism — checked at every call site, not at runtime.
When a function calls multiple library functions that each return different error types, you need a unified application error type. The thiserror crate generates the boilerplate automatically. The #[from] attribute generates From<SourceError> implementations automatically, making ? propagation work across error type boundaries.
use thiserror::Error; use axum::{http::StatusCode, response::{IntoResponse,Response},Json}; #[derive(Error, Debug)] pub enum AppError { #[error("not found")] NotFound, #[error("unauthorized: {0}")] Unauthorized(String), #[error("bad request: {0}")] BadRequest(String), // #[from] generates From<sqlx::Error> for AppError automatically // This makes ? work on sqlx queries in handlers returning Result<_, AppError> #[error("database error")] Database(#[from] sqlx::Error), #[error("jwt error")] Jwt(#[from] jsonwebtoken::errors::Error), #[error("internal: {0}")] Internal(#[from] anyhow::Error), } // impl IntoResponse makes AppError automatically convert to HTTP responses. // Any handler returning Result<_, AppError> gets correct HTTP status codes. impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, msg) = match &self { AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()), AppError::Unauthorized(_)=> (StatusCode::UNAUTHORIZED, self.to_string()), AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()), AppError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "database error".into()), AppError::Jwt(_) => (StatusCode::UNAUTHORIZED, "invalid token".into()), AppError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal error".into()), }; (status, Json(serde_json::json!({"error": msg}))).into_response() } } // Result: clean handlers that focus on happy-path logic async fn list_alerts(State(s): State<AppState>) -> Result<Json<Vec<Alert>>, AppError> { let alerts = sqlx::query_as!(Alert, "SELECT * FROM alerts ORDER BY created_at DESC") .fetch_all(&s.db) .await?; // sqlx::Error auto-converts to AppError::Database Ok(Json(alerts)) }
Use types to make invalid alert states impossible
An alert in the NOC system can be: Pending (just created), Acknowledged by a specific operator with a timestamp, or Resolved with a resolution note and timestamp. Design this as a Rust enum where "resolved with no note" and "acknowledged with no operator" cannot be constructed. Then implement:
- A function
acknowledge(alert: Alert, operator_id: Uuid) -> Result<Alert, String>that transitions Pending → Acknowledged, and returns Err if the alert is already acknowledged or resolved. - A function
resolve(alert: Alert, note: String) -> Result<Alert, String>that transitions Acknowledged → Resolved only (you cannot resolve a pending alert directly — it must be acknowledged first).
The state machine is encoded in the types — the compiler enforces the transitions.
Build a three-layer error hierarchy using thiserror
Create three error types: SensorError (for hardware read failures), ServiceError (wrapping SensorError or adding validation errors), and ApiError (wrapping ServiceError, adding HTTP-specific variants). Use #[from] to enable ? propagation through all three layers. Write a function that exercises all three error paths and verify with tests.
Write a testable display driver using traits
Define the NumberDisplay trait from §3.5. Implement it for a MockDisplay struct that records all calls made to it in a Vec. Write a test that: creates a MockDisplay, calls show_number(1234), and asserts that show_segments was called four times with the correct segment encodings for digits 1, 2, 3, and 4. This is the pattern for testing hardware drivers without physical hardware — a technique used throughout embedded Rust's professional ecosystem.