Skip to main content
Back to articles
2026 / 08
| 4 min read

Validation and freshness in Rust

Using Requisite's Rust types to make an input check visible to the code that depends on it, and why freshness still needs a clock.

rust types validation
On this page

Once a customer number has been parsed, it is easy to pass the resulting integer around and lose any indication of how it got there. Was it read directly from a request? Has it been through the check this particular operation requires? A u64 tells us the range of values it can hold, but it doesn’t answer either of those questions.

Requisite puts some of that information into the type. Its first Rust release, from August, has a wrapper called Tainted<T, Tr>: T is the value and Tr records whether it has passed through a validation transition. The receiving function can then require the result of that transition in its argument, instead of relying on every caller to remember the convention.

There is a second question once that function starts using data: how long does the check remain useful? Parsing an unchanged string as a number doesn’t expire with time. A cached status fetched for that customer can become too old, though, even while the program still holds it. Requisite handles those requirements differently, and following the value from the input check into a cache lookup makes the distinction easier to see.

Here is a small, complete example using version 0.1.0:

use requisite::prelude::*;

fn cache_key(id: Tainted<u64, Trusted>) -> String {
    format!("customer:{}", id.into_inner())
}

fn main() -> Result<(), std::num::ParseIntError> {
    let input = Tainted::<_, Untrusted>::from_input("42");
    let id = try_sanitize(input, |text| text.parse::<u64>())?;
    println!("{}", cache_key(id));
    Ok(())
}

This prints customer:42. Parsing is the policy in this example, so a value that cannot be parsed as an unsigned integer takes the error path. try_sanitize only produces the Trusted wrapper when the supplied function succeeds, and the type of the wrapped value changes from text to an integer along the way.

There are two checks here. The parser runs when the program runs; the compiler checks whether the value passed to cache_key has the required type. If I remove the transition and try to pass an untrusted integer to that function, Rust rejects the call. The repository’s compile-fail tests exercise that boundary, along with attempts to promote a value without going through the transition.

What the marker actually records

Trusted is a fairly strong name for a deliberately limited fact. It records that the value passed through the supplied function. It cannot establish that the function was sensible, that it checked the right policy, or that customer 42 is somebody the current user is allowed to access.

The implementation makes this visible. The trust parameter is a PhantomData marker, and the transition calls a closure before constructing the new wrapper. I could supply a closure that simply returns its input. That would satisfy the type transition while accomplishing very little.

That boundary matters when deciding where to use the wrapper. Putting a well-named validator beside the operation that needs it gives a reviewer somewhere to look, and requiring the tagged value prevents accidentally skipping the transition. The common Trusted marker doesn’t distinguish one validator from another, though. Two operations with different policies still need an API design that keeps those policies straight.

A value can become too old while you hold it

Suppose the next operation uses the validated number to retrieve a cached customer status. The identifier has passed its parsing check, but that tells us nothing about how recently the status was fetched. A compiler can’t decide in advance whether the cached record will be thirty seconds old when a particular request reaches it.

Requisite’s Fresh<T> keeps the value, a monotonic fetch time and a time-to-live. Each call to get() compares the current age with that TTL. It returns a reference while the value is within the limit, or an error containing its age and TTL once it is too old. The caller has to decide whether to fetch again, use a fallback or abandon the operation.

RequirementWhere it is checkedWhat remains the application’s job
The next function receives a tagged valueRust’s type checkerRequire the tag at the right boundary
The input satisfies a policyThe validation closureDefine and implement that policy
The cached value is within its TTLFresh::get() at runtimeChoose a useful TTL and handle expiry

A successful get() doesn’t reserve freshness for the rest of the program. The returned reference can be held while more time passes, so an operation that needs a recent reading should check near the point where it uses it. Similarly, assigning a new fetch time to old data would only tell the wrapper an inaccurate story.

For that customer cache, the two failure paths need different handling. An invalid identifier can be rejected before the lookup; an expired record might cause another fetch using the identifier we already checked. The tagged value is still useful while that happens, and the replacement record gets a new freshness check when it is used.