Schemas and Validated Input
APIs can turn unstructured external input into domain values—or accept domain values directly and eliminate validation.
Clap: a command schema becomes a typed value
Consumer goal. Accept one of two deployment environments and a positive integer-shaped replica count without manually indexing argument strings.
use clap::{Parser, ValueEnum};
#[derive(Debug, Clone, ValueEnum)]
enum Environment {
Staging,
Production,
}
#[derive(Debug, Parser)]
#[command(name = "ship")]
struct Cli {
#[arg(value_enum)]
environment: Environment,
#[arg(short, long, default_value_t = 1)]
replicas: u8,
}
fn main() {
let cli = Cli::parse_from(["ship", "production", "--replicas", "3"]);
println!("environment: {:?}", cli.environment);
println!("replicas: {}", cli.replicas);
let error = Cli::try_parse_from(["ship", "preview"]).unwrap_err();
println!("invalid environment: {:?}", error.kind());
}
Observed stdout.
environment: Production
replicas: 3
invalid environment: InvalidValue
Boundary behavior. ship preview is rejected before main receives a
Cli; generated help lists staging and production. --replicas many is
also rejected during parsing. The exact diagnostic text is version-sensitive.
Compile-time guarantee. Application code cannot receive an unknown
Environment variant, and replicas is already a u8.
Runtime boundary. Command-line text still needs parsing. The schema does
not make 0 invalid; a range constraint or domain newtype would be needed.
Tradeoff. Derive keeps the call site and model compact, but important parser behavior is generated rather than visible in ordinary functions.
Deep-dive question. How do Parser and ValueEnum derives produce both a
runtime parser and metadata for help/error rendering?
Serde: the consumer owns the data model
Consumer goal. Decode external JSON naming conventions into an ordinary Rust struct with a defaulted field, then encode the same value again.
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct User {
id: u64,
display_name: String,
#[serde(default)]
admin: bool,
}
fn main() -> serde_json::Result<()> {
let input = r#"{"id":1,"displayName":"Alice"}"#;
let user: User = serde_json::from_str(input)?;
println!("user: {} / {} / admin={}", user.id, user.display_name, user.admin);
println!("json: {}", serde_json::to_string(&user)?);
let error = serde_json::from_str::<User>(
r#"{"id":"one","displayName":"Alice"}"#,
)
.unwrap_err();
println!("invalid id category: {:?}", error.classify());
Ok(())
}
Observed stdout.
user: 1 / Alice / admin=false
json: {"id":1,"displayName":"Alice","admin":false}
invalid id category: Data
Boundary behavior. If id is a string, deserialization returns a typed
error identifying the incompatible location and expected Rust type.
Compile-time guarantee. Successful decoding produces a fully formed User;
downstream code does not repeatedly check whether id is numeric.
Runtime boundary. JSON syntax, missing required fields, and external values
cannot be known at compile time. A plain String still cannot enforce a domain
rule such as “display name is nonempty.”
Tradeoff. Attributes co-locate format policy with domain types, which is convenient but can couple a model to wire-format concerns.
Deep-dive question. How does Serde separate its format-independent data
model from serde_json’s parser and serializer?
HTTP: build a protocol value without performing I/O
Consumer goal. Construct a typed HTTP request that another library can send, route, test, or transform.
use http::{header::CONTENT_TYPE, Method, Request};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let request = Request::builder()
.method(Method::POST)
.uri("https://example.com/widgets")
.header(CONTENT_TYPE, "application/json")
.body(r#"{"name":"gizmo"}"#)?;
println!("{} {}", request.method(), request.uri());
println!(
"content-type: {}",
request.headers()[CONTENT_TYPE].to_str()?
);
println!("body: {}", request.body());
println!(
"invalid URI rejected: {}",
Request::builder().uri("http://[").body(()).is_err()
);
Ok(())
}
Observed stdout.
POST https://example.com/widgets
content-type: application/json
body: {"name":"gizmo"}
invalid URI rejected: true
Boundary behavior. An invalid URI or header value is preserved as a builder
error and returned when .body(...) completes the request.
Compile-time guarantee. Method, URI, headers, and generic body occupy named protocol roles instead of an unstructured tuple or string.
Runtime boundary. Parsing arbitrary text remains fallible. Header values
are bytes, so displaying one as text requires the explicit .to_str() check.
Tradeoff. Deferring errors keeps fluent chaining readable but separates an error from the method call that introduced it.
Deep-dive question. Why does the builder store an internal error, and how
does Request<T> remain independent of any network implementation?
Regex: compile once, borrow structured matches
Consumer goal. Parse a repeated log-line shape and name the pieces without allocating new strings for every match.
use regex::Regex;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let log = "2026-08-14 INFO user=alice action=login";
let pattern = Regex::new(
r"^(?<date>\d{4}-\d{2}-\d{2}) (?<level>[A-Z]+) user=(?<user>\w+)",
)?;
let captures = pattern
.captures(log)
.ok_or("log line did not match")?;
println!("date: {}", &captures["date"]);
println!("level: {}", &captures["level"]);
println!("user: {}", &captures["user"]);
println!("invalid pattern rejected: {}", Regex::new("(").is_err());
Ok(())
}
Observed stdout.
date: 2026-08-14
level: INFO
user: alice
invalid pattern rejected: true
Boundary behavior. Invalid pattern syntax fails at Regex::new; valid
patterns that do not match return None from captures.
Compile-time guarantee. The capture object cannot outlive the input text it borrows. The compiler prevents a dangling matched substring.
Runtime boundary. Pattern validity and whether input matches remain dynamic. Indexing with a misspelled capture name can panic; optional accessors trade brevity for checked lookup.
Tradeoff. A compiled regex makes repeated matching efficient, but its mini-language moves part of the program outside Rust’s type checker.
Deep-dive question. How do capture lifetimes connect the compiled program, the searched input, and the returned substrings?