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()?;- Installation
- Feature flags
- Quick start
- The trait hierarchy
- Error handling
- Parsing from strings
- Serde support
- Database / ORM integration
- Roadmap
- Contributing
| 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 |
[dependencies]
arvo = { version = "1.0", features = ["contact", "serde"] }Enable only the modules you need — unused features add zero dependencies.
| 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:
serdeandfullare orthogonal — combine them freely:features = ["full", "serde"]
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 EmailAddressarvo 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());
}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}"),
}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:
PhoneNumberandPostalAddressdo not implementTryFrom<&str>— their canonical strings are not unambiguously reversible back to a structured input.
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).
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() }
}
}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
| 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 |
Contributions are welcome! Please read CONTRIBUTING.md before opening a PR.
- Bug? → open a bug report
- Feature idea? → open a feature request
- Security issue? → see SECURITY.md — do not open a public issue
MIT License — © Codegress