Skip to content

codegress-com/arvo

arvo

Validated, immutable value objects for common domain types

arvo — Finnish for value

Crates.io docs.rs CI License: MIT MSRV: 1.85


Each type in arvo carries a single guarantee: if it exists, it is valid.

Construction always goes through ::new() returning Result — invalid states become unrepresentable at the type level. No more stringly-typed domain values, no runtime surprises.

// This compiles. This is guaranteed valid. Forever.
let email: EmailAddress = "user@example.com".try_into()?;

Contents

Documentation

Document Description
docs/value-objects.md What value objects are, simple vs composite, normalisation
docs/implementing.md How to implement the traits for custom types
docs/contact.md Reference for all contact module types
docs/finance.md Reference for all finance module types
docs/geo.md Reference for all geo module types
docs/measurement.md Reference for all measurement module types
docs/net.md Reference for all net module types
docs/identifiers.md Reference for all identifiers module types
docs/primitives.md Reference for all primitives module types
docs/temporal.md Reference for all temporal module types

Installation

[dependencies]
arvo = { version = "1.0", features = ["contact", "serde"] }

Enable only the modules you need — unused features add zero dependencies.


Feature flags

Feature What you get Extra deps
contact EmailAddress, CountryCode, PhoneNumber, PostalAddress, Website regex, url
finance Money, CurrencyCode, Iban, Bic, VatNumber, Percentage, ExchangeRate, CreditCardNumber, CardExpiryDate rust_decimal, chrono
geo Latitude, Longitude, Coordinate, BoundingBox, TimeZone, CountryRegion
measurement Length, Weight, Temperature, Volume, Area, Speed, Pressure, Energy, Power, Frequency
net Url, Domain, IpV4Address, IpV6Address, IpAddress, Port, MacAddress, MimeType, HttpStatusCode, ApiKey url
identifiers Slug, Ean13, Ean8, Isbn13, Isbn10, Issn, Vin
primitives NonEmptyString, BoundedString, PositiveInt, NonNegativeInt, PositiveDecimal, NonNegativeDecimal, Probability, HexColor, Locale, Base64String rust_decimal, base64
temporal UnixTimestamp, BirthDate, ExpiryDate, TimeRange, BusinessHours chrono
serde Serialize / Deserialize on all types — deserialisation validates serde
full All domain modules all of the above

Tip: serde and full are orthogonal — combine them freely: features = ["full", "serde"]


Quick start

use arvo::contact::{CountryCode, PhoneNumber, PhoneNumberInput};
use arvo::prelude::*;

// Construct via new() — validates and normalises on construction
let email = EmailAddress::new("User@Example.COM".into())?;
assert_eq!(email.value(), "user@example.com");  // always lowercase
assert_eq!(email.domain(), "example.com");

// Ergonomic try_into from &str
let email: EmailAddress = "hello@example.com".try_into()?;

// Country code — normalised to uppercase, ISO 3166-1 alpha-2
let country = CountryCode::new("cz".into())?;
assert_eq!(country.value(), "CZ");

// Composite value object — structured input, multiple accessors
let phone = PhoneNumber::new(PhoneNumberInput {
    country_code: CountryCode::new("CZ".into())?,
    number: "123 456 789".into(),   // formatting stripped automatically
})?;
assert_eq!(phone.value(), "+420123456789");
assert_eq!(phone.calling_code(), "+420");

// Invalid input → descriptive error, not a panic
let err = EmailAddress::new("not-an-email".into()).unwrap_err();
println!("{err}");  // 'not-an-email' is not a valid EmailAddress

The trait hierarchy

arvo uses two traits:

// Base trait — all value objects
pub trait ValueObject: Sized + Clone + PartialEq {
    type Input;
    type Error: std::error::Error;

    fn new(value: Self::Input) -> Result<Self, Self::Error>;
    fn into_inner(self) -> Self::Input;
}

// Subtrait — simple single-primitive newtypes only
pub trait PrimitiveValue: ValueObject {
    type Primitive: ?Sized;
    fn value(&self) -> &Self::Primitive;
}

Simple types implement both — value() returns the inner primitive:

let email = EmailAddress::new("user@example.com".into())?;
email.value()       // &String → "user@example.com"
email.into_inner()  // String  → "user@example.com"

Composite types implement only ValueObject — data is accessed through dedicated methods:

let phone = PhoneNumber::new(PhoneNumberInput { country_code, number })?;
phone.value()        // &str → "+420123456789"  (inherent method, not trait)
phone.calling_code() // &str → "+420"
phone.into_inner()   // PhoneNumberInput { country_code, number }

Use PrimitiveValue as a generic bound when you need access to the inner value:

fn print_value<T: PrimitiveValue<Primitive = str>>(v: &T) {
    println!("{}", v.value());
}

Error handling

All validation errors are variants of ValidationError:

use arvo::errors::ValidationError;

match EmailAddress::new("bad".into()) {
    Ok(email)  => println!("valid: {email}"),
    Err(ValidationError::InvalidFormat { type_name, value }) => {
        eprintln!("'{value}' is not a valid {type_name}");
    }
    Err(ValidationError::Empty { type_name }) => {
        eprintln!("{type_name} must not be empty");
    }
    Err(e) => eprintln!("{e}"),
}

Parsing from strings

Every type implements TryFrom<&str> (and therefore .try_into()) that parses the canonical string representation and validates in one step:

// Simple types parse their primitive value
let lat: Latitude       = "48.8588".try_into()?;
let port: Port          = "8080".try_into()?;
let ts: UnixTimestamp   = "1700000000".try_into()?;
let dob: BirthDate      = "1990-06-15".try_into()?;

// Composite types parse their canonical string format
let money: Money        = "10.50 EUR".try_into()?;
let rate: ExchangeRate  = "EUR/USD 1.0850".try_into()?;
let coord: Coordinate   = "48.858844, 2.294351".try_into()?;
let len: Length         = "1.5 km".try_into()?;
let range: TimeRange    = "2025-01-01 10:00:00 UTC / 2025-01-01 12:00:00 UTC".try_into()?;
let hours: BusinessHours = "Mon 09:00–17:00".try_into()?;

Parsing errors return ValidationError just like ::new().

Note: PhoneNumber and PostalAddress do not implement TryFrom<&str> — their canonical strings are not unambiguously reversible back to a structured input.


Serde support

Enable the serde feature. All types serialize as their raw primitive and deserialisation validates — invalid values are rejected at parse time:

use arvo::contact::EmailAddress;

let email = EmailAddress::new("user@example.com".into())?;
let json = serde_json::to_string(&email)?;
// → "\"user@example.com\""

// Deserialisation goes through new() — domain validation is enforced
let parsed: EmailAddress = serde_json::from_str(r#""hello@example.com""#)?;

// Invalid values are rejected at parse time
let err: Result<EmailAddress, _> = serde_json::from_str(r#""not-an-email""#);
assert!(err.is_err());

Composite types (PostalAddress) serialise as their structured Input type (JSON object).


Database / ORM integration

arvo intentionally has no database dependency. Integrate using the accessors arvo provides — this works with any ORM and enables multi-column storage for composite types:

Raw sqlx — simple types:

// Bind — extract the primitive
query.bind(email.value())
query.bind(country.value())

// Read back — construct via new()
let s: String = row.get("email");
let email = EmailAddress::new(s)?;

SeaORM / Diesel — composite types as multiple columns:

// Define your own entity with individual columns
#[derive(DeriveEntityModel)]
pub struct Model {
    pub street: String, pub city: String,
    pub zip: String,    pub country: String,
}

// Convert via into_inner()
impl From<PostalAddress> for Model {
    fn from(addr: PostalAddress) -> Self {
        let i = addr.into_inner();
        Model { street: i.street, city: i.city,
                zip: i.zip, country: i.country.into_inner() }
    }
}

Roadmap

62 value object types planned across 8 domain modules. Types are only added when they bring validation, normalisation, or domain semantics that existing crates don't already provide.

Feature Highlights Types Status
contact EmailAddress, PhoneNumber, CountryCode, PostalAddress, Website 5 5 / 5 ✅
identifiers Slug, Ean13, Isbn13, Vin 7 7 / 7 ✅
finance Money, CurrencyCode, Iban, Bic, VatNumber, Percentage, ExchangeRate, CreditCardNumber, CardExpiryDate 9 9 / 9 ✅
temporal UnixTimestamp, BirthDate, ExpiryDate, TimeRange, BusinessHours 5 5 / 5 ✅
geo Latitude, Longitude, Coordinate, BoundingBox, TimeZone, CountryRegion 6 6 / 6 ✅
net Url, Domain, IpV4Address, IpV6Address, IpAddress, Port, MacAddress, MimeType, HttpStatusCode, ApiKey 10 10 / 10 ✅
measurement Length, Weight, Temperature, Volume, Area, Speed, Pressure, Energy, Power, Frequency 10 10 / 10 ✅
primitives NonEmptyString, BoundedString, Locale, HexColor 10 10 / 10 ✅

→ Full details and design rationale in ROADMAP.md


Migration from 0.x to 1.0

What changed Migration
ValueObject::value() moved to PrimitiveValue Change T: ValueObject to T: PrimitiveValue if you call .value() generically
type Output removed from ValueObject Replace <T as ValueObject>::Output with the concrete type
XxxOutput type aliases removed Replace EmailAddressOutput with String, PortOutput with u16, etc.
sql feature removed Use .value() / .into_inner() to bind primitives; implement sqlx traits yourself if needed

Contributing

Contributions are welcome! Please read CONTRIBUTING.md before opening a PR.


MIT License — © Codegress

About

Validated, immutable value objects for common domain types (email, money, identifiers, …)

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages