witnessed

Crates.iowitnessed
lib.rswitnessed
version0.3.1
created_at2026-01-24 08:03:06.499841+00
updated_at2026-01-25 03:53:46.86179+00
descriptionType-level witness wrapper for carrying validated invariants.
homepage
repositoryhttps://github.com/jcfangc/witnessed
max_upload_size
id2066378
size59,261
(jcfangc)

documentation

README

witnessed

A small Rust pattern for carrying validated invariants through the type system.

Idea

Define a witness W for some carrier type T. Construct Witnessed<T, W> only via W::witness (or the convenience Witnessed::try_new). Downstream code can then require Witnessed<...> in function signatures, preventing “unattested value” bugs across decoupled functions/modules.

A witness may also normalize input (e.g. trimming strings) while validating.

Why not just a newtype?

A plain newtype wraps data, but it is still forgeable by downstream crates unless you keep constructors private. Witnessed<T, W> makes the “validated boundary” explicit and reusable: the proof lives in the type parameter W, so the same carrier T can be attested by different policies without proliferating wrapper types.

Auto-traits (Send/Sync)

Witnessed<T, W> encodes W at the type level without owning it (PhantomData<fn() -> W>), so Send/Sync are driven by T rather than being accidentally constrained by W.

Example: General Usage

use witnessed::{Witness, Witnessed};

#[derive(Debug, PartialEq, Eq)]
enum IdxErr {
    OutOfRange { idx: usize },
}

struct IdxLt3;
impl Witness<usize> for IdxLt3 {
    type Error = IdxErr;
    fn attest(x: usize) -> Result<usize, Self::Error> {
        (x < 3).then_some(x).ok_or(IdxErr::OutOfRange { idx: x })
    }
}

fn pick(xs: &[i32; 3], idx: Witnessed<usize, IdxLt3>) -> i32 {
    xs[*idx]
}

#[derive(Debug, PartialEq, Eq)]
enum StrErr {
    Empty,
}

struct TrimNonEmpty;
impl Witness<String> for TrimNonEmpty {
    type Error = StrErr;
    fn attest(s: String) -> Result<String, Self::Error> {
        let s = s.trim().to_owned();
        (!s.is_empty()).then_some(s).ok_or(StrErr::Empty)
    }
}

fn main() {
    let xs = [10, 20, 30];

    // === boundary: parse/compute -> witness ===
    let raw = "2".parse::<usize>().unwrap();
    let computed = raw + 2; // 4, would panic if used directly as index

    match Witnessed::<usize, IdxLt3>::try_new(computed) {
        Ok(idx) => println!("picked = {}", pick(&xs, idx)),
        Err(e) => println!("index rejected: {:?}", e),
    }

    // === normalization demo ===
    let name = Witnessed::<String, TrimNonEmpty>::try_new("   hi   ".into()).unwrap();
    println!("normalized = {:?}", name.as_ref()); // "hi"
}

Zero-Cost Wrapper

Witnessed is a zero-cost wrapper in terms of its memory layout and runtime performance. It uses Rust's type system to encode validation and invariants without introducing additional memory overhead. However, it's important to note that the validation or invariant checks themselves may still incur some cost at runtime.

Key Points:

  • Memory Efficiency: Witnessed<T, W> has the same memory size as T because it uses PhantomData to encode the witness type W at the type level, without storing it. Thus, there is no additional memory allocation for W.
  • Validation Cost: The cost of validating the invariants or normalizing the data (e.g., trimming strings, range checks) is still present. These checks happen during the creation of a Witnessed instance, and their runtime cost is unavoidable.
  • Compile-Time Safety: Validation rules are enforced at compile time via Rust's type system, which ensures correctness, but does not eliminate the actual runtime costs of the validation logic itself.

Example: Memory Size Test

#[cfg(test)]
mod witness_size_tests {
    use super::*;
    use core::mem;

    struct Any;
    impl Witness<i32> for Any {
        type Error = core::convert::Infallible;
        fn attest(input: i32) -> Result<i32, Self::Error> {
            Ok(input)
        }
    }

    #[test]
    fn witnessed_size_is_equal_to_inner_size() {
        let w = Witnessed::<i32, Any>::try_new(42).unwrap();
        // Verifies the size of `Witnessed<T, W>` is the same as `T`
        assert_eq!(mem::size_of::<Witnessed<i32, Any>>(), mem::size_of::<i32>());
    }
}

In summary, Witnessed provides compile-time guarantees through the type system, but the runtime cost of validation remains an inherent part of the process.

no_std

This crate supports #![no_std]: the core API (Witness, Witnessed) depends only on core. Unit tests use std via #[cfg(test)] extern crate std;.

Note: the crate does not require alloc, but your own witnesses may choose to validate/normalize String/Vec etc. (which requires an allocator on the consumer side).

Pattern

  • Put validation/normalization at boundaries (parsing, request decoding, DB reads).
  • Accept Witnessed<T, W> in internal APIs that assume the invariant.
  • Use into_inner() only when you explicitly want to drop the guarantee.

Compared to refined_type

refined_type models rules as types and provides a rich set of composable rule combinators. This works very well when the refinement rule is known at compile time.

witnessed intentionally stays smaller and pushes composition into user code.

Advantage: dynamic policies are natural

In many real systems, validation depends on runtime inputs (request flags, tenant config, feature gates, A/B experiments, environment, etc.). Encoding such “dynamic rule trees” in the type system can become awkward or impossible when the rule structure is only known at runtime.

With witnessed, you write the policy directly in W::attest and keep the result (the proof that it passed) in the type parameter W. The type-level guarantee stays simple even if the validation logic is dynamic.

Trade-off: less uniformity and fewer built-in combinators

Because each Witness can define its own structured Error, there is no single, uniform error type that makes generic rule-combinator libraries trivial to build. If you want AND/OR/NOT-style composition with unified reporting, you typically implement it inside a witness (or add your own adapters) rather than relying on a standardized combinator stack.

Why Witness<T> (generic) instead of type Target (associated type)?

This crate makes an explicit choice in the Rust type-system trade-off space: polymorphism (one witness, many target types) versus uniqueness (one witness, one target type).

The core constraint: associated types must be unique per implementor

In Rust, once a type W implements a trait with an associated type, that associated type is fixed for W. You cannot implement the same trait again for the same W with a different associated type.

If Witness were defined like this:

trait Witness {
    type Target;
    type Error;
    fn attest(input: Self::Target) -> Result<Self::Target, Self::Error>;
}

then a witness type like NonEmpty would be forced to pick exactly one Target forever:

struct NonEmpty;

impl Witness for NonEmpty {
    type Target = String;
    /* ... */
}

// You cannot also do:
// impl Witness for NonEmpty { type Target = Vec<u8>; ... }
// because `NonEmpty` already implemented `Witness` once.

For a general-purpose utility crate, this quickly becomes restrictive: users often want to apply the same logical invariant (e.g. “non-empty”, “sorted”, “bounded”, “ASCII”, “normalized”) to different carriers (String, Vec<T>, maps, domain collections, etc.). With associated types, they must introduce boilerplate witness types like NonEmptyString, NonEmptyVec, NonEmptyMap, purely to satisfy the uniqueness rule.

The generic design: one witness can apply to many T

By making the target an explicit generic parameter:

pub trait Witness<T>: Sized {
    type Error;
    fn attest(input: T) -> Result<T, Self::Error>;
}

the same witness type can be reused across multiple targets:

struct NonEmpty;

impl Witness<String> for NonEmpty { /* ... */ }
impl<T> Witness<Vec<T>> for NonEmpty { /* ... */ }

This is a better fit for a small “pattern” crate: it keeps the witness as a reusable logic label, while T is the concrete carrier being validated/normalized.

What we give up by not using type Target

Using an associated Target can produce simpler type names in some designs, especially when the witness type is itself a domain object (e.g. Email, UserId, OrderId) where you never intend to witness any other carrier:

struct Email; // semantically bound to `String` forever

In that strongly domain-coupled style, type Target can read nicely and prevent misuse by construction.

witnessed chooses the opposite end: it optimizes for reusable invariants rather than one-off domain wrappers. If you want a domain-specific refined type, you can still build it on top of Witness<T> by defining a dedicated witness type per domain concept.

Why Error stays an associated type

While T benefits from polymorphism, Error benefits from determinism. Once you pick which witness (W) and which carrier (T) you are validating, the error type should be stable and precise. Keeping type Error as an associated type allows structured, domain-friendly errors (enums with fields) without forcing a single global error model.

Tests

cargo test
Commit count: 8

cargo fmt