Why I Like Rust (... or any sufficiently expressive type system)
The fundamental flow of systems is to prepare some state and execute on that state when ready. Bugs arise when execution occurs with invalid state. We see this when making requests to servers with invalid parameters, when attempting to interface with hardware using incorrect configurations, and in many other cases. Often, the solution to such problems is exceptional code flow at runtime. You have to attempt a process, and then catch any errors that arise. There are other patterns that help mitigate this issue, but many put the onus on the programmer to keep track of this meta-state.
What if, instead, we had protections and guarantees within the type system to enforce proper handling of state? That way, the user couldn't accidentally call a function at an inappropriate time, or act on data that wasn't ready. The following code snippet demonstrates the use of "zero-sized types" to ergonomically prevent accidental use of certain interfaces until they are valid to use.
// Demonstration of using zero-sized types to give static (compile time) // guardrails to prevent misuse of an interface use std::{error::Error, marker::PhantomData}; // Zero-sized types to distinguish between operating state pub struct APIRequestUnverified {} pub struct APIRequestVerified {} // Holds configuration for our request pub struct APIRequest<State = APIRequestUnverified> { // Cool Zero-sized type - Takes no space at runtime state: PhantomData<State>, // API Requirement: Must have at least one of these set // before any API call is valid part1: Option<String>, part2: Option<String>, } // This implementation applies to all instances of APIRequest impl APIRequest { pub fn new() -> Self { Self { state: PhantomData, part1: None, part2: None, } } } // This implementation applies only to APIRequest's with Unverified status // This provides a builder pattern to configure the request impl APIRequest<APIRequestUnverified> { pub fn part1(mut self, part1: Option<String>) -> Self { self.part1 = part1; self } // ... Same builder setter for part2 // Note the return type pub fn build(self) -> Result<APIRequest<APIRequestVerified>, Box<dyn Error>> { // 1. Enforce our requirement that part1 or part2 are valid // 2. If invalid, return Err // 3. If valid, return APIRequest with phantomdata type of Verified Ok(APIRequest::<APIRequestVerified> { state: PhantomData, part1: self.part1, part2: self.part2, // Populated with builder values }) } } // This implementation only applies to APIRequest's that are Verified impl APIRequest<APIRequestVerified> { // Do something now that we have valid API parameters pub fn make_request(&self) -> Result<(), Box<dyn Error>> { Ok(()) } } fn main() -> Result<(), Box<dyn Error>>{ println!("Hello, person actually compiling this demo!"); let request = APIRequest::new(); // Uncomment this to generate an LSP/compiler error! // This function is not available for request yet! //request.make_request(); match request.part1(Some("Hi".to_owned())).build() { Ok(req) => req.make_request(), // Now that it's verified, it is! Err(e) => return Err(e), } }
This code is compilable, just be sure to select the "Show hidden lines" icon before copying.
This idea extends far beyond simple API requests. In embedded development, we can use the same patterns to guarantee, at compile time, that we aren't attempting to use the same hardware resource two different ways at the same time. This level of expression, and the mental offloading it provides me when I code, are why I enjoy writing rust.