Minimal improvements on the newtype pattern.
Value objects are a common design pattern across programming languages, and Rust's newtype pattern provides a straightforward way to implement them. However, enforcing validity constraints on these objects typically requires significant boilerplate code.
This crate aims to reduce this boilerplate by providing a lightweight macro attribute that extends the newtype pattern with validation and normalization capabilities. The macro automatically enforces domain invariants at construction time, enabling you to guarantee that only valid values can exist within your domain.
use valobj::{value_object, Validate};
#[value_object(Normalize, Validate)]
pub struct Email(String);
impl Normalize<String> for Email {
fn normalize(value: String) -> String {
value.trim().to_string()
}
}
impl Validate<String> for Email {
fn validate(value: &String) -> Result<(), valobj::Error> {
if value.contains('@') && value.contains('.') {
Ok(())
} else {
Err(valobj::Error::InvalidValue(
"Email must contain '@' and '.' characters".to_string(),
))
}
}
}
fn main() {
// try_from will normalize the input value and validate it, ensuring that only valid emails can be created
if let Ok(email) = Email::try_from("USER@example.com".to_string()) {
assert_eq!(email.as_ref(), "user@example.com");
}
}Creating new types that wrap primitives helps you avoid Primitive obsession, a code smell where primitive types like String or u64 are used directly to represent domain concepts without meaningful constraints or semantics.
If you already use newtype pattern, you can consider using value_object attribute when you need to
normalize or validate the inner value, or when you want to define invariants easily (e.g.
ensure a
UserId is always positive or that a Username is not empty...).
Value objects can be constructed with either from or try_from methods, depending on whether
validation is enabled or not.
To maintain consistency, the tuple is immutable and you cannot access the .0 field directly.
To get the inner value, a get method is generated, which returns a copy (or a &str
in case of string) to the inner value:
#[valobj::value_object]
pub struct UserId(u64);
fn main() {
let user_id = UserId::from(1);
let value = user_id.0; // This will not compile
let value = user_id.get(); // This will work
}Some other traits are also implemented to allow easy access:
AsRef<T>(AsRef<str>in case of aStringtype)Deref<Target=T>
#[valobj::value_object]
pub struct UserId(u64);
fn main() {
let user_id = UserId::from(1);
let value = *user_id;
}You can define a validation function that checks if the input value meets some requirements. If
the Validate trait is implemented, a TryFrom implementation will be generated,
allowing you to create value objects from the inner type while ensuring that the value is valid.
To enable validation, you need to :
- Add
Validateattribute to the macro:#[valobj::value_object(Validate)] - Implement the
Validatetrait for the inner type of your value object.
pub trait Validate<T> {
fn validate(value: &T) -> std::result::Result<(), Error>;
} You can define a normalization function that transforms the input value into a canonical form (e.g. trim whitespaces from a string, change it to lowercase...).
To enable normalization, you need to :
- Add
Normalizeattribute to the macro:#[valobj::value_object(Normalize)] - Implement the
Normalizetrait for the inner type of your value object.
The normalize method should return the normalized value.
pub trait Normalize<T> {
fn normalize(value: T) -> T;
}