Option, Result, Errors, and Valid States
AI output is uncertain. Your program state does not need to be.
An AI application receives malformed JSON, missing media metadata, provider failures, policy
violations, and semantically wrong answers as ordinary operating conditions. Rust gives each
condition a type. The payoff is not merely fewer crashes: callers can choose the correct recovery
policy without parsing strings or guessing what false meant.
Learning objectives
Section titled “Learning objectives”By the end of this chapter, you will be able to:
- choose
Option<T>,Result<T, E>, orpanic!from the meaning of a failure; - use
?, combinators, and pattern matching without hiding important branches; - design concrete error enums whose variants drive retries, repair, review, and refusal;
- keep source errors for operators while presenting safe errors to users;
- distinguish decoding, structural validation, semantic verification, and authorization;
- use validated wrappers and typestate to prevent unsafe state transitions;
- investigate a model-output failure without flattening it into “something went wrong”; and
- run and extend the companion validation example.
Where this chapter fits
Section titled “Where this chapter fits”You should first understand expressions, structs, enums, and exhaustive matching. This chapter builds a vocabulary for failure that every later component uses:
untrusted bytes │ decode: Result<Parsed, DecodeError> ▼parsed value │ validate: Result<Validated, ValidationError> ▼validated value │ verify: Result<Verified, Vec<Violation>> ▼verified value │ authorize: Result<Authorized, PolicyError> ▼effectAsync workers will later use these distinctions to decide whether to retry. HTTP handlers will map them to safe responses. The harness will turn contract violations into bounded repair feedback. Evals will count each class separately.
Absence, recoverable failure, and broken invariants
Section titled “Absence, recoverable failure, and broken invariants”The first design decision is semantic.
| Meaning | Rust representation | Example |
|---|---|---|
| A value may legitimately be absent | Option<T> | an image may have no embedded caption |
| An operation may recoverably fail | Result<T, E> | provider request timed out |
| The program violated its own invariant | panic or assertion | an unreachable internal state was constructed |
Option<T> has Some(T) and None. It deliberately carries no explanation. That is useful when
absence is the whole story:
fn embedded_caption(metadata: &serde_json::Value) -> Option<&str> { metadata.get("caption").and_then(serde_json::Value::as_str)}Do not use None for “JSON was malformed,” “the user is unauthorized,” and “the provider timed
out.” Those cases require different actions, so they require different error values.
Result<T, E> has Ok(T) and Err(E). It is marked #[must_use]; ignoring it produces a warning.
That is part of Rust’s safety model: failure cannot silently disappear unless code explicitly
discards it.
Panic is for a bug or an invariant that the current program says is impossible, not for bad model output, a dropped network connection, or a user typo. At application startup it can be reasonable to terminate when required configuration is absent. Inside a long-running service, the same condition should normally become a typed initialization error before the server begins accepting traffic.
Read Result as a control-flow type
Section titled “Read Result as a control-flow type”Consider a loader that must decode and then validate:
fn decode_and_validate(raw: &str) -> Result<ValidatedTask, LoadTaskError> { let task = serde_json::from_str::<Task>(raw) .map_err(|source| LoadTaskError::Decode { source })?; Ok(task.try_into()?)}The ? operator means:
- if the expression is
Ok(value), unwrap the value and continue; - if it is
Err(error), convert the error withFromwhen needed; - return that converted error immediately from the enclosing function.
It is propagation, not logging, retrying, or erasing. The layer with enough policy context should decide what to do next.
Use combinators when they express one local transformation:
let hint = task.input .get("hint") .and_then(serde_json::Value::as_str) .unwrap_or("none");Use match when branches have different business meaning:
match run_result { Ok(report) => publish(report), Err(RunError::ProviderUnavailable { .. }) => schedule_retry(), Err(RunError::InvalidOutput { violations }) => request_repair(violations), Err(RunError::NeedsReview { reason }) => open_review(reason), Err(error) => fail_run(error),}A long chain of combinators is not automatically idiomatic. Prefer the form that makes ownership, short-circuiting, and policy easiest to audit.
Design errors for the caller
Section titled “Design errors for the caller”An error type is an API. Name variants after facts a caller can act on:
#[derive(Debug, thiserror::Error)]enum RunError { #[error("input could not be decoded")] Decode { #[source] source: DecodeError, },
#[error("provider unavailable after {attempts} attempts")] ProviderUnavailable { attempts: u8 },
#[error("model output violated the contract")] InvalidOutput { violations: Vec<Violation> },
#[error("tool call requires permission: {capability}")] PermissionRequired { capability: Capability },
#[error("run exhausted its {kind} budget")] BudgetExhausted { kind: BudgetKind },
#[error("human judgment required: {reason}")] NeedsReview { reason: String },}Avoid a single Message(String) variant. It forces the caller to parse prose. Avoid variants named
Internal when the underlying classes have different recovery behavior. Avoid one enum with every
error in the system: it couples unrelated layers and makes public APIs unstable.
Use a concrete enum in libraries when callers should match variants. In an application boundary,
an opaque report can be appropriate when the only action is to log context and exit. This book uses
thiserror for reusable, typed errors. An application may add an error-reporting crate at the
outermost CLI boundary, but should not erase domain distinctions before policy is applied.
Preserve the source chain
Section titled “Preserve the source chain”The display message and diagnostic source serve different audiences:
#[derive(Debug, thiserror::Error)]enum LoadTaskError { #[error("task JSON could not be decoded")] Decode { #[source] source: serde_json::Error, },
#[error("task violated a domain invariant")] Domain { #[from] source: DomainError, },}#[source] lets operators inspect the causal chain. #[from] additionally generates a conversion,
so ? can turn DomainError into LoadTaskError. Add #[from] only when the conversion is
unambiguous and context-free. If you need a filename, provider request ID, or retry count, construct
the variant explicitly.
Replace ambiguous primitives
Section titled “Replace ambiguous primitives”These fields compile but carry weak meaning:
struct Request { model: String, timeout: u64, temperature: f32, input: Vec<u8>,}Use names, units, and validating constructors:
struct ModelId(String);struct Timeout(std::time::Duration);struct Temperature(f32);struct EncodedMedia(Vec<u8>);
impl Temperature { fn new(value: f32) -> Result<Self, ConfigError> { if value.is_finite() && (0.0..=2.0).contains(&value) { Ok(Self(value)) } else { Err(ConfigError::InvalidTemperature(value)) } }}The is_finite check matters: comparisons with NaN can defeat naïve numeric validation. A newtype
also stops a timeout in milliseconds from being passed where seconds were expected.
Keep fields private when construction establishes an invariant. If any module can write
Temperature(900.0), the constructor is only a convention. Expose observation methods such as
as_f32, not unrestricted mutation.
Parse, validate, verify, authorize
Section titled “Parse, validate, verify, authorize”These stages prove different claims:
- Decode proves bytes match a syntax and can populate Rust fields.
- Structural validation proves local invariants such as non-empty IDs and bounded sizes.
- Semantic verification proves relationships using evidence or external state.
- Authorization proves the principal may perform the requested effect now.
Valid JSON is not necessarily a valid task. A syntactically valid date can be nonexistent. A well-formed tool call can attempt to read outside the workspace. A factually correct rollback plan can still lack approval.
let decoded: IncidentReport = serde_json::from_str(&raw)?;let validated: Validated<IncidentReport> = policy.validate(decoded)?;let verified: Verified<IncidentReport> = verifier.check(validated, &evidence)?;let authorized: Authorized<IncidentReport> = authorization.approve(verified, principal)?;execute(authorized).awaitDo not call every stage “validation.” Precise names preserve the security argument and make traces useful.
Validated wrappers
Section titled “Validated wrappers”A private tuple field can carry proof established at a boundary:
struct ValidatedTask(Task);
impl TryFrom<Task> for ValidatedTask { type Error = DomainError;
fn try_from(task: Task) -> Result<Self, Self::Error> { task.validate()?; Ok(Self(task)) }}Downstream functions accepting ValidatedTask no longer need defensive checks. This is valuable
when the invariant is important and the value crosses several layers. It is excessive for a
temporary integer used in one small function.
The wrapper proves only what its constructor checked. If external facts can change—permissions, file contents, deployment state—the proof may need a timestamp, version, hash, or narrow lifetime. Types do not make stale authorization current.
Typestate for high-consequence transitions
Section titled “Typestate for high-consequence transitions”Consider a generated video edit:
struct Draft<T>(T);struct Verified<T>(T);struct Approved<T>(T);
fn verify( plan: Draft<CutPlan>, media: &MediaIndex,) -> Result<Verified<CutPlan>, VerifyError>;
fn approve( plan: Verified<CutPlan>, approval: HumanApproval,) -> Result<Approved<CutPlan>, PolicyError>;
async fn render( plan: Approved<CutPlan>,) -> Result<RenderReceipt, RenderError>;There is no render(Draft<_>). Moving the value into the next state also prevents accidentally
executing an old draft after approval.
Alternatives:
- one enum is best when states must be stored together or transitions are dynamic;
- separate wrapper types are best when APIs differ substantially by state;
- a runtime state machine is necessary when transitions are data-driven;
- boolean flags are acceptable only when combinations cannot become contradictory.
Three booleans—verified, approved, and rendered—allow eight combinations, several nonsense.
An enum or typestate represents only the meaningful states.
Aggregating violations versus returning early
Section titled “Aggregating violations versus returning early”Configuration loading usually benefits from reporting every independent error at once. An authorization check should return immediately once access is denied. Choose based on the user’s next action.
For model output, collect independent field violations:
let violations = contract .rules() .filter_map(|rule| rule.check(&candidate).err()) .collect::<Vec<_>>();
if violations.is_empty() { Ok(VerifiedOutput::new(candidate))} else { Err(violations)}This gives a repair model precise feedback in one bounded attempt. Do not continue checks that assume an earlier safety prerequisite. For example, do not inspect image dimensions after decoding failed, and do not follow a filesystem path before root confinement succeeds.
Operator diagnostics and user-safe errors
Section titled “Operator diagnostics and user-safe errors”One failure needs two representations.
The operator record may include:
- run, trace, task, model, and provider request IDs;
- error source chain and retry count;
- schema, prompt, model, and tool versions;
- elapsed time and consumed budget;
- hashes of sensitive arguments or artifacts.
The user response should include:
- a stable public error code;
- a safe explanation;
- whether retrying or changing input may help;
- a correlation ID for support.
Never place secrets, raw prompts, private file paths, authorization tokens, or untrusted model output directly in a public error. Also treat model-generated error prose as untrusted data: truncate it, escape it for the destination, and label it as model output.
Runnable companion implementation
Section titled “Runnable companion implementation”The complete example is
crates/mosaic-domain/examples/results_and_validation.rs. Run it from the companion workspace:
cd rust/rust-ai-engineeringcargo run -q -p mosaic-domain --example results_and_validationcargo test --workspace --all-featuresExpected output:
objective: Summarize the incident from supplied evidencehint: prefer deployment timestampsThe example:
- deserializes an untrusted JSON task;
- converts it to a private
ValidatedTask; - distinguishes decoding from domain validation;
- retains
serde_json::Erroras a source without exposing its details inDisplay; - uses
Option<&str>for a genuinely optional hint; and - includes a test proving malformed JSON and an empty objective take different error paths.
Trace the ownership: raw is borrowed, deserialization owns the new Task, try_into consumes it,
and successful validation moves it into ValidatedTask. No clone is necessary.
Failure investigation: “all bad tasks are decode errors”
Section titled “Failure investigation: “all bad tasks are decode errors””Symptom: a valid JSON task with an empty objective is counted as malformed input.
Likely cause: decoding and domain validation were combined into one string error, or a broad
map_err(|_| LoadError::Invalid) erased the source variant.
Investigation:
- save the exact input as a redacted fixture;
- run
serde_json::from_str::<Task>alone; - inspect whether it returns a populated
Task; - call
Task::validateseparately; - assert the exact enum variant, not the display string;
- inspect any HTTP/CLI error conversion for a catch-all mapping.
Fix: preserve stage-specific variants. Add a regression test like the companion example. Update metrics so decode and invariant failures remain separate.
Why it matters: retrying malformed syntax with the same payload is pointless; returning field guidance for a domain violation may let a caller repair it immediately.
Common failure modes
Section titled “Common failure modes”unwrap on model or network data
Section titled “unwrap on model or network data”unwrap turns expected bad input into process termination. Use it in narrowly scoped tests when a
failure means the test setup is broken. In production, propagate or handle the error.
Stringly typed errors
Section titled “Stringly typed errors”Matching "timeout" inside an error message breaks when wording changes. Preserve a typed timeout
variant and format prose only at the boundary.
Retrying permanent failures
Section titled “Retrying permanent failures”Retries do not fix authorization denial, schema incompatibility, or an output larger than the hard limit. Classify errors before applying backoff.
Returning raw provider errors
Section titled “Returning raw provider errors”Provider bodies may contain request fragments, account metadata, or unstable wording. Store restricted diagnostics and map them to a stable public error.
Trusting deserialization
Section titled “Trusting deserialization”Serde establishes shape, not business truth. Call validation after deserialization and keep constructors as the normal way to create invariant-bearing values.
Stale typestate
Section titled “Stale typestate”An Approved<T> created yesterday may not be approved after policy or resource state changes.
Bind approval to a version/hash and verify freshness at execution.
Performance and security implications
Section titled “Performance and security implications”Option and Result are ordinary enums and often have efficient representations; do not replace
them with sentinel integers for speculative speed. Measure the real workload.
Error paths still need budgets. Cap:
- raw error body bytes;
- violation count and message length;
- source-chain depth shown to an operator;
- retries per error class;
- diagnostic artifact retention.
Validation order affects both cost and security. Perform cheap size, type, and confinement checks before expensive decoding, embedding, model inference, or media processing. Avoid logging a full invalid payload merely because validation failed.
Large error enums can increase future size when stored inside every state value. Usually that cost is irrelevant compared with network and inference work. If profiling proves otherwise, box a large source payload or store a compact error code plus an external diagnostic record—without erasing the semantic variant.
Exercises
Section titled “Exercises”Recall
Section titled “Recall”- What semantic difference separates
Option<T>fromResult<T, E>? - What does
?do with the error value? - Why is a JSON schema insufficient for authorization?
- When is
#[from]less appropriate than constructing a variant explicitly?
Implementation
Section titled “Implementation”- Extend the companion example with
max_input_bytes. Reject zero and values above 16 MiB. - Add an optional
correlation_id; convert it to a required field only at the HTTP boundary. - Change validation to return multiple independent field violations.
- Create
Draft<CutPlan>,Verified<CutPlan>, andApproved<CutPlan>and prove that only the approved plan can be passed torender. - Add a public error view that exposes a stable code but not the source error.
Design
Section titled “Design”Design the errors and state transitions for this flow:
- a user uploads a video;
- a decoder checks container, codec, dimensions, and duration;
- a model proposes a cut list;
- deterministic code checks every interval and evidence reference;
- a human approves the preview;
- a renderer writes the final media;
- cancellation may happen during decoding, inference, or rendering.
Decide which failures are retryable, repairable, reviewable, or terminal. State which proof can go stale and how execution detects it.
Solution guidance
Section titled “Solution guidance”For the size exercise, prefer a validating newtype such as MaxInputBytes with a private field.
Return variants for zero and excessive size; do not silently clamp configuration.
For multiple violations, deserialize first, then collect local invariant errors into
Vec<Violation>. Stop immediately if decoding fails because no safe field-level value exists.
For typestate, make wrapper fields private and consume self during transitions. The render
function should accept Approved<CutPlan>, not CutPlan plus approved: bool.
For the public view, match the internal enum to (code, safe_message, retryable). Log a correlation
ID with the restricted diagnostic chain. Test that secrets inserted into a source error do not
appear in the serialized public response.
Check your understanding
Section titled “Check your understanding”- Can a value be syntactically valid but structurally invalid? Give one task example.
- Which error variant should cause a harness repair rather than a provider retry?
- Why can a validating constructor be bypassed if the newtype field is public?
- What fact does
ValidatedTaskprove, and what facts does it not prove? - When would one state enum be easier to operate than multiple typestate wrappers?
- Where should provider request IDs be retained, and where should they be redacted?
Completion checklist
Section titled “Completion checklist”- I can explain absence, recoverable failure, and broken invariants separately.
- I can use
?without claiming it handles or logs an error. - I can design variants around caller policy.
- I preserve source errors without exposing sensitive diagnostics.
- I validate after deserialization.
- I use private fields for invariant-bearing newtypes.
- I can identify when typestate is valuable and when it is needless ceremony.
- I ran the companion example and its test.
- I completed at least one implementation exercise without using
unwrapon untrusted data.