Tech Wiki

TOPICSSERIES

[Rust Zero to Production 13] Model Valid States with Rust Enums, Option, Result, and Patterns

Storing an endpoint check as is_up: bool plus status_code: 0 looks compact. It also creates interpretation work. Does false mean the endpoint is down or that no check has run? Which number distinguishes a DNS failure from a timeout? Does a missing success time use 0, and if so, how is that different from Unix timestamp 0? Some field combinations describe states that should never exist.

Rust can encode the alternatives as enum variants, absence as Option<T>, and a fallible operation as Result<T, E>. A match then lets the compiler verify that every possible case is covered. This article puts those types together in one endpoint-checking model.

1. Completed Example

The standalone Rust 2024 crate lives at examples/article-13-domain-states. It has no external crates and does not depend on the shared endpoint-monitor.

[package]
name = "article-13-domain-states"
version = "0.1.0"
edition = "2024"
publish = false

[lints.rust]
unsafe_code = "forbid"

[lints.clippy]
all = "warn"
pedantic = "warn"

Run these commands from the project directory:

cd examples/article-13-domain-states
cargo fmt --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet

2. Boolean and Sentinel Loss

A flat check record often starts like this:

struct LooseCheckState {
    is_up: bool,
    status_code: u16,
    error_code: i32,
    last_success_at: u64,
}

The type permits is_up == true alongside error_code != 0. Rules such as “status code 0 means no HTTP response” and “error code -2 means DNS failure” live outside the type. When a failure kind is added, every producer and consumer must agree on the same sentinel table.

A boolean is not inherently wrong. It fits an independent yes-or-no property. Trouble starts when one value must be exactly one of Pending, Healthy, or Unhealthy, but several fields are used to imitate that choice.

3. State and Data in Enum Variants

CheckState names the three states directly. Each variant carries only the data valid in that state.

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckFailure {
    Dns,
    Timeout { limit_ms: u64 },
    HttpStatus { status_code: u16 },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckState {
    Pending,
    Healthy {
        status_code: u16,
        latency_ms: u64,
    },
    Unhealthy {
        failure: CheckFailure,
        last_success_at: Option<u64>,
    },
}

Pending has no HTTP status. Healthy has a successful status and latency but no failure. Unhealthy must contain a CheckFailure, while its last successful timestamp may be absent. There is no way to construct a value that is healthy and unhealthy at once.

The nested enum has a separate job. CheckState describes the check lifecycle; CheckFailure classifies why a check is unhealthy. Folding them into one large enum would mix lifecycle and failure policy. Splitting failures that every consumer handles identically would create the opposite problem: extra arms without a useful distinction. Variant boundaries should follow information that callers actually handle differently.

4. Option: Absence; Result: Success or Failure

Option<T> and Result<T, E> are both standard-library enums, but they answer different questions.

  • Option<u64> says the last-success time is either Some(timestamp) or None. None is absence, not an error code.
  • Result<HttpObservation, TransportError> says this transport operation ended in Ok(observation) or Err(error).
  • CheckState converts that one operation into a domain state that can be stored.

The classifier makes the three boundaries visible.

#[must_use]
pub fn classify_check(
    observation: Result<HttpObservation, TransportError>,
    last_success_at: Option<u64>,
) -> CheckState {
    match observation {
        Ok(HttpObservation {
            status_code,
            latency_ms,
        }) if (200..=299).contains(&status_code) => CheckState::Healthy {
            status_code,
            latency_ms,
        },
        Ok(HttpObservation { status_code, .. }) => CheckState::Unhealthy {
            failure: CheckFailure::HttpStatus { status_code },
            last_success_at,
        },
        Err(TransportError::Dns) => CheckState::Unhealthy {
            failure: CheckFailure::Dns,
            last_success_at,
        },
        Err(TransportError::Timeout { limit_ms }) => CheckState::Unhealthy {
            failure: CheckFailure::Timeout { limit_ms },
            last_success_at,
        },
    }
}

An Ok transport result does not necessarily mean a healthy endpoint. Receiving HTTP 503 completes the transport but produces CheckState::Unhealthy in this domain. That is why the example separates transport-level TransportError from monitoring-level CheckFailure. The timeout limit and HTTP status remain attached to their variants, so logging and retry code need not reverse-map magic numbers.

The first arm uses a match guard. Its pattern extracts the observation fields, and the guard checks whether the status lies between 200 and 299. A false guard proceeds to the next Ok arm. A guarded arm does not count as covering every Ok, so the later arm handles the remaining response codes.

5. Exhaustive match and Change Points

A match runs the first arm whose pattern fits, and its arms must cover every possible value. This compile-fail example deliberately omits Unhealthy.

#[derive(Debug)]
enum CheckState {
    Pending,
    Healthy,
    Unhealthy,
}

fn label(state: CheckState) -> &'static str {
    match state {
        CheckState::Pending => "pending",
        CheckState::Healthy => "healthy",
    }
}

fn main() {}

With rustc 1.98.1, E0004 names the missing variant.

error[E0004]: non-exhaustive patterns: `CheckState::Unhealthy` not covered
  --> tests/ui/non_exhaustive_match.rs:9:11
   |
 9 |     match state {
   |           ^^^^^ pattern `CheckState::Unhealthy` not covered
   |
note: `CheckState` defined here
  --> tests/ui/non_exhaustive_match.rs:2:6
   |
 2 | enum CheckState {
   |      ^^^^^^^^^^
...
 5 |     Unhealthy,
   |     --------- not covered
   = note: the matched value is of type `CheckState`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
   |
11 ~         CheckState::Healthy => "healthy",
12 ~         CheckState::Unhealthy => todo!(),
   |

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0004`.

This check is useful when an enum gains a variant. A match that lists variants explicitly fails wherever the new case needs a decision. A _ wildcard also catches variants added later, so it can hide that signal in core domain logic where every state deserves review. A wildcard can still be the right choice when all remaining cases genuinely share one policy, such as a UI filter that intentionally ignores unrelated input.

6. match for All Cases, if let for One

Turning a state into human-readable text needs an answer for all three variants, so match fits. Inside Unhealthy, let...else ends the no-history path early.

#[must_use]
pub fn describe_state(state: &CheckState) -> String {
    match state {
        CheckState::Pending => "pending: no check has run".to_owned(),
        CheckState::Healthy {
            status_code,
            latency_ms,
        } => format!("healthy: HTTP {status_code} in {latency_ms} ms"),
        CheckState::Unhealthy {
            failure,
            last_success_at,
        } => {
            let Some(timestamp) = last_success_at else {
                return format!("unhealthy: {failure}; never succeeded");
            };
            format!("unhealthy: {failure}; last success at {timestamp}")
        }
    }
}

The else block in let Some(timestamp) = last_success_at else { ... }; must diverge, here by returning. After it, timestamp is an ordinary u64. This shape reads well when the success pattern should remain on the main path and the failure case should exit early.

If code records healthy states and intentionally does nothing for every other state, if let is shorter.

pub fn record(&mut self, state: &CheckState) {
    if let CheckState::Healthy {
        status_code,
        latency_ms,
    } = state
    {
        self.entries.push((*status_code, *latency_ms));
    }
}

if let handles one pattern concisely, but it gives up the exhaustiveness requirement of match. Using it in a classifier where every variant needs a different policy could silently ignore a state added later. The important question is not which syntax is shortest. It is whether omission should be a compile error.

7. Handle the Type Without unwrap

Introducing Option and Result only to call unwrap() immediately discards their main benefit at the call site. A narrow internal invariant can sometimes prove failure impossible, but endpoint input and network results usually do not meet that condition.

The production path in this example uses match, if let, and let...else to handle each relevant case. A library boundary can return Result when its caller owns the recovery decision. The ? operator can forward a failure when the current function only adds context. Code at the recovery-policy boundary can match individual variants. One construct does not need to win everywhere.

8. State Check

Formatting, all-target checks, Clippy with warnings denied, tests, and execution should succeed on stable rustc 1.98.1, Cargo 1.98.1, and Rust 2024. The suite contains five unit tests and one E0004 diagnostic regression test. Compiler wording can change with the toolchain, so the example compares stderr byte for byte.

The exact stdout from cargo run --quiet is:

healthy: HTTP 204 in 37 ms
unhealthy: HTTP status 503; last success at 1700000000
unhealthy: timed out after 800 ms; never succeeded
recorded success: HTTP 204 in 37 ms

This model has no is_up flag or failure sentinel. Variants constrain valid states, Option marks optional data, and Result marks a fallible operation. When another state is added, exhaustive matches point to the code that needs a new decision.

Full source code

The complete runnable source for this article is available in the Chapter 13 project on GitHub.

Sources


Leave a Reply

Your email address will not be published. Required fields are marked *

Tech Wiki

Built with WordPress · Learn in public.