944 Validated Type
Tutorial
The Problem
Use Rust's type system to enforce domain invariants at construction time via smart constructors. Implement NonEmptyString and PositiveInt as opaque newtypes whose inner fields are private. Consumers can only obtain values of these types by calling a validated constructor that returns Result, making invalid states unrepresentable at compile time.
🎯 Learning Outcomes
Result<T, String> and enforce invariants at the boundaryNonEmptyString::concat is always non-empty)Display for newtype wrapperspub vs no modifier)Code Example
#![allow(clippy::all)]
// Smart constructors: enforce invariants at the type level.
// The type is opaque — you can only create values through validated constructors.
// ── NonEmptyString ──────────────────────────────────────────────────────────
/// A string guaranteed to be non-empty.
/// The inner field is private; construction goes through `create`.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct NonEmptyString(String);
impl NonEmptyString {
pub fn create(s: &str) -> Result<Self, String> {
if !s.is_empty() {
Ok(NonEmptyString(s.to_string()))
} else {
Err("string must be non-empty".to_string())
}
}
pub fn value(&self) -> &str {
&self.0
}
pub fn len(&self) -> usize {
self.0.len()
}
/// Concatenate two NonEmptyStrings — result is always non-empty.
pub fn concat(&self, other: &NonEmptyString) -> NonEmptyString {
NonEmptyString(format!("{}{}", self.0, other.0))
}
}
impl std::fmt::Display for NonEmptyString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
// ── PositiveInt ─────────────────────────────────────────────────────────────
/// An integer guaranteed to be strictly positive (> 0).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct PositiveInt(i64);
impl PositiveInt {
pub fn create(n: i64) -> Result<Self, String> {
if n > 0 {
Ok(PositiveInt(n))
} else {
Err(format!("{} is not positive", n))
}
}
pub fn value(self) -> i64 {
self.0
}
/// Addition of two PositiveInts — result is always positive.
pub fn add(self, other: Self) -> Self {
PositiveInt(self.0 + other.0)
}
/// Multiplication of two PositiveInts — result is always positive.
pub fn mul(self, other: Self) -> Self {
PositiveInt(self.0 * other.0)
}
}
impl std::fmt::Display for PositiveInt {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
// ── Validated accumulating error type ───────────────────────────────────────
// Goes beyond the OCaml example: a Validated<T> that collects ALL errors.
#[derive(Debug, PartialEq)]
pub enum Validated<T> {
Ok(T),
Err(Vec<String>),
}
impl<T> Validated<T> {
pub fn ok(v: T) -> Self {
Validated::Ok(v)
}
pub fn err(e: impl Into<String>) -> Self {
Validated::Err(vec![e.into()])
}
pub fn is_ok(&self) -> bool {
matches!(self, Validated::Ok(_))
}
/// Map over a successful value.
pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Validated<U> {
match self {
Validated::Ok(v) => Validated::Ok(f(v)),
Validated::Err(es) => Validated::Err(es),
}
}
/// Combine two Validated values, collecting errors from both.
pub fn and<U>(self, other: Validated<U>) -> Validated<(T, U)> {
match (self, other) {
(Validated::Ok(a), Validated::Ok(b)) => Validated::Ok((a, b)),
(Validated::Err(mut e1), Validated::Err(e2)) => {
e1.extend(e2);
Validated::Err(e1)
}
(Validated::Err(e), _) | (_, Validated::Err(e)) => Validated::Err(e),
}
}
pub fn errors(&self) -> Option<&[String]> {
match self {
Validated::Err(es) => Some(es),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_non_empty_string_ok() {
let s = NonEmptyString::create("hello").unwrap();
assert_eq!(s.value(), "hello");
assert_eq!(s.len(), 5);
}
#[test]
fn test_non_empty_string_err() {
assert!(NonEmptyString::create("").is_err());
}
#[test]
fn test_positive_int_ok() {
let n = PositiveInt::create(42).unwrap();
assert_eq!(n.value(), 42);
}
#[test]
fn test_positive_int_err() {
assert!(PositiveInt::create(0).is_err());
assert!(PositiveInt::create(-5).is_err());
}
#[test]
fn test_positive_int_add() {
let a = PositiveInt::create(3).unwrap();
let b = PositiveInt::create(4).unwrap();
assert_eq!(a.add(b).value(), 7);
}
#[test]
fn test_validated_accumulates_errors() {
let v1: Validated<i32> = Validated::err("error 1");
let v2: Validated<i32> = Validated::err("error 2");
let combined = v1.and(v2);
assert_eq!(combined.errors().unwrap().len(), 2);
}
#[test]
fn test_validated_ok() {
let v1 = Validated::ok(1_i32);
let v2 = Validated::ok(2_i32);
assert_eq!(v1.and(v2), Validated::Ok((1, 2)));
}
}Key Differences
| Aspect | Rust | OCaml |
|---|---|---|
| Encapsulation | Private struct field (not pub) | Abstract type in module signature |
| Constructor gating | Smart constructor returning Result | Same pattern |
| Safe derived ops | Methods that skip re-validation | Same; compiler cannot verify but convention holds |
Copy semantics | #[derive(Copy)] for cheap types | Value semantics throughout (no Copy concept) |
| Ordering | #[derive(PartialOrd, Ord)] — lexicographic by default | compare function or module Ord |
This pattern — make invalid states unrepresentable — is one of the most effective uses of static type systems in both languages. The cost is a single validation at the boundary; the benefit is zero-cost safety everywhere the type is used.
OCaml Approach
(* Opaque type via module signature *)
module NonEmptyString : sig
type t
val create : string -> (t, string) result
val value : t -> string
val concat : t -> t -> t
end = struct
type t = string
let create s =
if String.length s > 0 then Ok s
else Error "string must be non-empty"
let value s = s
let concat a b = a ^ b
end
module PositiveInt : sig
type t
val create : int -> (t, string) result
val value : t -> int
val add : t -> t -> t
val mul : t -> t -> t
end = struct
type t = int
let create n =
if n > 0 then Ok n
else Error (string_of_int n ^ " is not positive")
let value n = n
let add a b = a + b
let mul a b = a * b
end
OCaml uses the module system's signature/implementation split to create opaque types. The signature exposes type t without revealing that t = string, so external code cannot construct or pattern-match on values of t directly.
Full Source
#![allow(clippy::all)]
// Smart constructors: enforce invariants at the type level.
// The type is opaque — you can only create values through validated constructors.
// ── NonEmptyString ──────────────────────────────────────────────────────────
/// A string guaranteed to be non-empty.
/// The inner field is private; construction goes through `create`.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct NonEmptyString(String);
impl NonEmptyString {
pub fn create(s: &str) -> Result<Self, String> {
if !s.is_empty() {
Ok(NonEmptyString(s.to_string()))
} else {
Err("string must be non-empty".to_string())
}
}
pub fn value(&self) -> &str {
&self.0
}
pub fn len(&self) -> usize {
self.0.len()
}
/// Concatenate two NonEmptyStrings — result is always non-empty.
pub fn concat(&self, other: &NonEmptyString) -> NonEmptyString {
NonEmptyString(format!("{}{}", self.0, other.0))
}
}
impl std::fmt::Display for NonEmptyString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
// ── PositiveInt ─────────────────────────────────────────────────────────────
/// An integer guaranteed to be strictly positive (> 0).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct PositiveInt(i64);
impl PositiveInt {
pub fn create(n: i64) -> Result<Self, String> {
if n > 0 {
Ok(PositiveInt(n))
} else {
Err(format!("{} is not positive", n))
}
}
pub fn value(self) -> i64 {
self.0
}
/// Addition of two PositiveInts — result is always positive.
pub fn add(self, other: Self) -> Self {
PositiveInt(self.0 + other.0)
}
/// Multiplication of two PositiveInts — result is always positive.
pub fn mul(self, other: Self) -> Self {
PositiveInt(self.0 * other.0)
}
}
impl std::fmt::Display for PositiveInt {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
// ── Validated accumulating error type ───────────────────────────────────────
// Goes beyond the OCaml example: a Validated<T> that collects ALL errors.
#[derive(Debug, PartialEq)]
pub enum Validated<T> {
Ok(T),
Err(Vec<String>),
}
impl<T> Validated<T> {
pub fn ok(v: T) -> Self {
Validated::Ok(v)
}
pub fn err(e: impl Into<String>) -> Self {
Validated::Err(vec![e.into()])
}
pub fn is_ok(&self) -> bool {
matches!(self, Validated::Ok(_))
}
/// Map over a successful value.
pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Validated<U> {
match self {
Validated::Ok(v) => Validated::Ok(f(v)),
Validated::Err(es) => Validated::Err(es),
}
}
/// Combine two Validated values, collecting errors from both.
pub fn and<U>(self, other: Validated<U>) -> Validated<(T, U)> {
match (self, other) {
(Validated::Ok(a), Validated::Ok(b)) => Validated::Ok((a, b)),
(Validated::Err(mut e1), Validated::Err(e2)) => {
e1.extend(e2);
Validated::Err(e1)
}
(Validated::Err(e), _) | (_, Validated::Err(e)) => Validated::Err(e),
}
}
pub fn errors(&self) -> Option<&[String]> {
match self {
Validated::Err(es) => Some(es),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_non_empty_string_ok() {
let s = NonEmptyString::create("hello").unwrap();
assert_eq!(s.value(), "hello");
assert_eq!(s.len(), 5);
}
#[test]
fn test_non_empty_string_err() {
assert!(NonEmptyString::create("").is_err());
}
#[test]
fn test_positive_int_ok() {
let n = PositiveInt::create(42).unwrap();
assert_eq!(n.value(), 42);
}
#[test]
fn test_positive_int_err() {
assert!(PositiveInt::create(0).is_err());
assert!(PositiveInt::create(-5).is_err());
}
#[test]
fn test_positive_int_add() {
let a = PositiveInt::create(3).unwrap();
let b = PositiveInt::create(4).unwrap();
assert_eq!(a.add(b).value(), 7);
}
#[test]
fn test_validated_accumulates_errors() {
let v1: Validated<i32> = Validated::err("error 1");
let v2: Validated<i32> = Validated::err("error 2");
let combined = v1.and(v2);
assert_eq!(combined.errors().unwrap().len(), 2);
}
#[test]
fn test_validated_ok() {
let v1 = Validated::ok(1_i32);
let v2 = Validated::ok(2_i32);
assert_eq!(v1.and(v2), Validated::Ok((1, 2)));
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_non_empty_string_ok() {
let s = NonEmptyString::create("hello").unwrap();
assert_eq!(s.value(), "hello");
assert_eq!(s.len(), 5);
}
#[test]
fn test_non_empty_string_err() {
assert!(NonEmptyString::create("").is_err());
}
#[test]
fn test_positive_int_ok() {
let n = PositiveInt::create(42).unwrap();
assert_eq!(n.value(), 42);
}
#[test]
fn test_positive_int_err() {
assert!(PositiveInt::create(0).is_err());
assert!(PositiveInt::create(-5).is_err());
}
#[test]
fn test_positive_int_add() {
let a = PositiveInt::create(3).unwrap();
let b = PositiveInt::create(4).unwrap();
assert_eq!(a.add(b).value(), 7);
}
#[test]
fn test_validated_accumulates_errors() {
let v1: Validated<i32> = Validated::err("error 1");
let v2: Validated<i32> = Validated::err("error 2");
let combined = v1.and(v2);
assert_eq!(combined.errors().unwrap().len(), 2);
}
#[test]
fn test_validated_ok() {
let v1 = Validated::ok(1_i32);
let v2 = Validated::ok(2_i32);
assert_eq!(v1.and(v2), Validated::Ok((1, 2)));
}
}
Exercises
EmailAddress as an opaque type whose create validates presence of @ and at least one . after it.NonEmptyString::split_first() -> (char, &str) — guaranteed safe because the string is non-empty.BoundedInt { value: i64, min: i64, max: i64 } with a constructor that validates range.serde serialization that validates on deserialization (implement Deserialize with invariant check).PositiveInts and computes their ratio as f64 — always safe (no division by zero).