Skip to content

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.

By the end of this chapter, you will be able to:

  • choose Option<T>, Result<T, E>, or panic! 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.

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>
effect

Async 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.

MeaningRust representationExample
A value may legitimately be absentOption<T>an image may have no embedded caption
An operation may recoverably failResult<T, E>provider request timed out
The program violated its own invariantpanic or assertionan 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.

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:

  1. if the expression is Ok(value), unwrap the value and continue;
  2. if it is Err(error), convert the error with From when needed;
  3. 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.

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.

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.

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.

These stages prove different claims:

  1. Decode proves bytes match a syntax and can populate Rust fields.
  2. Structural validation proves local invariants such as non-empty IDs and bounded sizes.
  3. Semantic verification proves relationships using evidence or external state.
  4. 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).await

Do not call every stage “validation.” Precise names preserve the security argument and make traces useful.

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.

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.

The complete example is crates/mosaic-domain/examples/results_and_validation.rs. Run it from the companion workspace:

Terminal window
cd rust/rust-ai-engineering
cargo run -q -p mosaic-domain --example results_and_validation
cargo test --workspace --all-features

Expected output:

objective: Summarize the incident from supplied evidence
hint: prefer deployment timestamps

The example:

  1. deserializes an untrusted JSON task;
  2. converts it to a private ValidatedTask;
  3. distinguishes decoding from domain validation;
  4. retains serde_json::Error as a source without exposing its details in Display;
  5. uses Option<&str> for a genuinely optional hint; and
  6. 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:

  1. save the exact input as a redacted fixture;
  2. run serde_json::from_str::<Task> alone;
  3. inspect whether it returns a populated Task;
  4. call Task::validate separately;
  5. assert the exact enum variant, not the display string;
  6. 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.

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.

Matching "timeout" inside an error message breaks when wording changes. Preserve a typed timeout variant and format prose only at the boundary.

Retries do not fix authorization denial, schema incompatibility, or an output larger than the hard limit. Classify errors before applying backoff.

Provider bodies may contain request fragments, account metadata, or unstable wording. Store restricted diagnostics and map them to a stable public error.

Serde establishes shape, not business truth. Call validation after deserialization and keep constructors as the normal way to create invariant-bearing values.

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.

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.

  1. What semantic difference separates Option<T> from Result<T, E>?
  2. What does ? do with the error value?
  3. Why is a JSON schema insufficient for authorization?
  4. When is #[from] less appropriate than constructing a variant explicitly?
  1. Extend the companion example with max_input_bytes. Reject zero and values above 16 MiB.
  2. Add an optional correlation_id; convert it to a required field only at the HTTP boundary.
  3. Change validation to return multiple independent field violations.
  4. Create Draft<CutPlan>, Verified<CutPlan>, and Approved<CutPlan> and prove that only the approved plan can be passed to render.
  5. Add a public error view that exposes a stable code but not the source error.

Design the errors and state transitions for this flow:

  1. a user uploads a video;
  2. a decoder checks container, codec, dimensions, and duration;
  3. a model proposes a cut list;
  4. deterministic code checks every interval and evidence reference;
  5. a human approves the preview;
  6. a renderer writes the final media;
  7. 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.

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.

  • 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 ValidatedTask prove, 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?
  • 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 unwrap on untrusted data.