arvo 0.9.0

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

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 ValueObject trait 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 = "0.9", 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 once_cell, 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 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::*;

// Simple value object — validated and normalised 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, canonical E.164 output
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 ValueObject trait

Every type in arvo implements the same core interface:

pub trait ValueObject: Sized + Clone + PartialEq {
    /// What `new()` accepts — raw primitive for simple types,
    /// a dedicated input struct for composites.
    type Input;

    /// What `value()` returns — same as `Input` for simple types,
    /// canonical representation (e.g. E.164 string) for composites.
    type Output: ?Sized;

    type Error: std::error::Error;

    /// Only way to construct — validates and normalises the input.
    fn new(value: Self::Input) -> Result<Self, Self::Error>;

    /// Returns the validated output value.
    fn value(&self) -> &Self::Output;

    /// Consumes and returns the original input.
    fn into_inner(self) -> Self::Input;
}

Simple typeInput and Output are the same (String):

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

Composite typeInput is a struct, Output is canonical string:

let phone = PhoneNumber::new(PhoneNumberInput { country_code, number })?;
phone.value()       // &String → "+420123456789"  (E.164)
phone.into_inner()  // PhoneNumberInput { country_code, number }

You can implement it for your own domain types using the provided implementations as a reference.


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}"),
}

Serde support

Enable the serde feature. All types serialize as their raw primitive (transparent newtype):

use arvo::contact::EmailAddress;

let email = EmailAddress::new("user@example.com".into())?;

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

// Deserialization validates — invalid JSON values are rejected at parse time
let parsed: EmailAddress = serde_json::from_str(r#""hello@example.com""#)?;

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


Contributing

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


MIT License — © Codegress