| Crates.io | koruma |
| lib.rs | koruma |
| version | 0.3.0 |
| created_at | 2026-01-08 22:21:10.139627+00 |
| updated_at | 2026-01-10 01:33:51.563378+00 |
| description | The koruma crate |
| homepage | |
| repository | https://github.com/stayhydated/koruma |
| max_upload_size | |
| id | 2031199 |
| size | 66,913 |
A per-field validation library for Rust with struct-based errors.
None)#[koruma(nested)]#[koruma(newtype)]#[koruma(try_new)]provides a collection of common validators, with partial i18n support.
currently supported: en, fr
[dependencies]
koruma = { version = "*", features = ["derive"] }
bon = { version = "*" } # internally used by koruma
Use #[koruma::validator] to define validation rules. Each validator must have a field marked with #[koruma(value)] to capture the validated value:
For validators that work with multiple types, use generics with a blanket impl:
#[koruma::validator]
#[derive(Clone, Debug)]
pub struct RangeValidation::<T> {
pub min: T,
pub max: T,
#[koruma(value)]
pub actual: T,
}
// Use a blanket impl with trait bounds
impl<T: PartialOrd + Clone> Validate<T> for RangeValidation<T> {
fn validate(&self, value: &T) -> bool {
*value >= self.min && *value <= self.max
}
}
use koruma::{Validate as _, validator};
#[koruma::validator]
#[derive(Clone, Debug)]
pub struct NumberRangeValidation {
min: i32,
max: i32,
#[koruma(value)]
pub actual: i32, // The type matches what you're validating
}
impl Validate<i32> for NumberRangeValidation {
fn validate(&self, value: &i32) -> bool {
*value >= self.min && *value <= self.max
}
}
Apply validators to struct fields using #[derive(Koruma)] and the #[koruma(...)] attribute:
use koruma::Koruma;
#[derive(Koruma)]
pub struct User {
#[koruma(NumberRangeValidation(min = 0, max = 150))]
pub age: i32,
#[koruma(StringLengthValidation(min = 1, max = 100))]
pub name: String,
// Fields without #[koruma(...)] are not validated
pub internal_id: u64,
}
// Use `::<_>` (turbofish) to infer the type from the field
#[derive(Koruma)]
pub struct Measurements {
#[koruma(RangeValidation::<_>(min = 0.0, max = 100.0))]
pub temperature: f64,
#[koruma(RangeValidation::<_>(min = 0, max = 1000))]
pub pressure: i32,
}
The generated error struct provides typed access to each field's validation errors:
let user = User {
age: 200, // Invalid
name: "".to_string(), // Invalid
internal_id: 1,
};
match user.validate() {
Ok(()) => println!("Valid!"),
Err(errors) => {
// Access errors by field, then by validator
if let Some(age_err) = errors.age().number_range_validation() {
println!("Age {} is out of range", age_err.actual);
}
if let Some(name_err) = errors.name().string_length_validation() {
println!("Name is invalid: {:?}", name_err.input);
}
}
}
Apply multiple validators to a single field by separating them with commas:
#[derive(Koruma)]
pub struct Item {
// Must be in range 0-100 AND be even
#[koruma(NumberRangeValidation(min = 0, max = 100), EvenNumberValidation)]
pub value: i32,
}
// Access individual validators
let err = item.validate().unwrap_err();
if let Some(range_err) = err.value().number_range_validation() {
// Handle range error
}
if let Some(even_err) = err.value().even_number_validation() {
// Handle even number error
}
// Or get all failed validators at once
let all_errors = err.value().all(); // Vec<ItemValueValidator>
Use the each(...) syntax to validate each element in a Vec:
#[derive(Koruma)]
pub struct Order {
// Each score must be in range 0-100
#[koruma(each(RangeValidation::<_>(min = 0.0, max = 100.0)))]
pub scores: Vec<f64>,
}
// Errors include the index of the failing element
let order = Order {
scores: vec![50.0, 150.0, 75.0], // 150 is out of range
};
let err = order.validate().unwrap_err();
// Returns &[(usize, OrderScoresError)]
for (index, element_error) in err.scores() {
if let Some(range_err) = element_error.generic_range_validation() {
println!("Score at index {} is invalid: {}", index, range_err.actual);
}
}
Fields of type Option<T> are automatically handled:
None: Validation is skipped entirelySome(value): The inner value is validated#[derive(Koruma)]
pub struct UserProfile {
#[koruma(StringLengthValidation(min = 1, max = 50))]
pub username: String, // Required field
#[koruma(StringLengthValidation(min = 1, max = 200))]
pub bio: Option<String>, // Optional - skipped when None
#[koruma(NumberRangeValidation(min = 0, max = 150))]
pub age: Option<i32>, // Optional - skipped when None
}
// None fields are skipped
let profile = UserProfile {
username: "alice".to_string(),
bio: None, // Not validated
age: None, // Not validated
};
assert!(profile.validate().is_ok());
// Some fields are validated
let profile = UserProfile {
username: "bob".to_string(),
bio: Some("".to_string()), // Invalid: too short
age: Some(200), // Invalid: out of range
};
let err = profile.validate().unwrap_err();
// Error captures the inner value
let bio_err = err.bio().string_length_validation().unwrap();
assert_eq!(bio_err.input, "".to_string());
For fields that are themselves structs deriving Koruma, use #[koruma(nested)] to automatically validate them:
#[derive(Koruma)]
pub struct Address {
#[koruma(StringLengthValidation(min = 1, max = 100))]
pub street: String,
#[koruma(StringLengthValidation(min = 1, max = 50))]
pub city: String,
#[koruma(StringLengthValidation(min = 2, max = 10))]
pub zip_code: String,
}
#[derive(Koruma)]
pub struct Customer {
#[koruma(StringLengthValidation(min = 1, max = 100))]
pub name: String,
// Nested struct - will call Address::validate() automatically
#[koruma(nested)]
pub address: Address,
}
// Validation cascades through nested structs
let customer = Customer {
name: "Alice".to_string(),
address: Address {
street: "".to_string(), // Invalid: empty
city: "Springfield".to_string(),
zip_code: "12345".to_string(),
},
};
match customer.validate() {
Ok(()) => println!("Valid!"),
Err(errors) => {
// Access nested errors through the field getter
if let Some(address_err) = errors.address() {
if let Some(street_err) = address_err.street().string_length_validation() {
println!("Street is invalid: {:?}", street_err.input);
}
}
}
}
Nested validation also works with optional fields:
#[derive(Koruma)]
pub struct CustomerWithOptionalAddress {
#[koruma(StringLengthValidation(min = 1, max = 100))]
pub name: String,
// Optional nested struct - skipped when None, validated when Some
#[koruma(nested)]
pub shipping_address: Option<Address>,
}
// None is skipped
let customer = CustomerWithOptionalAddress {
name: "Bob".to_string(),
shipping_address: None, // Not validated
};
assert!(customer.validate().is_ok());
Nesting can be arbitrarily deep - nested structs can themselves contain nested structs:
#[derive(Koruma)]
pub struct Company {
#[koruma(StringLengthValidation(min = 1, max = 200))]
pub company_name: String,
#[koruma(nested)]
pub headquarters: Address,
}
#[derive(Koruma)]
pub struct Employee {
#[koruma(StringLengthValidation(min = 1, max = 100))]
pub employee_name: String,
#[koruma(nested)]
pub employer: Company, // Company contains nested Address
}
// Access deeply nested errors
let err = employee.validate().unwrap_err();
if let Some(company_err) = err.employer() {
if let Some(address_err) = company_err.headquarters() {
if let Some(city_err) = address_err.city().string_length_validation() {
println!("Company HQ city is invalid");
}
}
}
For single-field wrapper structs (newtypes), use #[koruma(newtype)] at both the struct level and field level to get transparent error access.
Use #[koruma(newtype)] at the struct level to mark a single-field struct as a newtype:
#[derive(Koruma)]
#[koruma(newtype)]
pub struct PositiveNumber {
#[koruma(RangeValidation::<_>(min = 0, max = 1000))]
pub value: i32,
}
// The error struct implements Deref, so you can access .all() directly
let num = PositiveNumber { value: -5 };
let err = num.validate().unwrap_err();
// Access validators directly via Deref
let all_errors = err.all(); // No need to go through .value()
if let Some(range_err) = err.range_validation() {
println!("Value {} is out of range", range_err.actual);
}
When using a newtype as a field in another struct, use #[koruma(newtype)] instead of #[koruma(nested)] to get transparent error access:
#[derive(Koruma)]
pub struct Order {
#[koruma(StringLengthValidation(min = 1, max = 100))]
pub description: String,
// Use newtype instead of nested for single-field wrappers
#[koruma(newtype)]
pub quantity: PositiveNumber,
}
let order = Order {
description: "Widget".to_string(),
quantity: PositiveNumber { value: -10 },
};
let err = order.validate().unwrap_err();
// Access nested newtype errors directly via Deref
// No need for .unwrap() or pattern matching on Option
let all_qty_errors = err.quantity().all();
if let Some(range_err) = err.quantity().range_validation() {
println!("Quantity {} is invalid", range_err.actual);
}
The difference between nested and newtype:
| Attribute | Use Case | Error Access |
|---|---|---|
#[koruma(nested)] |
Multi-field structs | err.field() returns Option<&InnerError> |
#[koruma(newtype)] |
Single-field wrappers | err.field() returns &Wrapper with Deref |
try_newUse #[koruma(try_new)] at the struct level to generate a try_new constructor that validates on creation:
#[derive(Koruma)]
#[koruma(try_new)]
pub struct ValidatedUser {
#[koruma(StringLengthValidation(min = 1, max = 50))]
pub username: String,
#[koruma(RangeValidation::<_>(min = 18, max = 150))]
pub age: i32,
}
// Use try_new instead of struct literal + validate
match ValidatedUser::try_new("alice".to_string(), 25) {
Ok(user) => println!("Created user: {}", user.username),
Err(errors) => {
if let Some(name_err) = errors.username().string_length_validation() {
println!("Invalid username");
}
}
}
// Equivalent to:
// let user = ValidatedUser { username: "alice".to_string(), age: 25 };
// user.validate()?;
You can combine try_new with newtype for validated wrapper types:
#[derive(Koruma)]
#[koruma(try_new, newtype)]
pub struct Email {
#[koruma(EmailValidation)]
pub value: String,
}
// Create validated email
let email = Email::try_new("user@example.com".to_string())?;
// Invalid emails are rejected at construction
let result = Email::try_new("not-an-email".to_string());
assert!(result.is_err());
For simple error messages, implement Display or a custom method on your validators:
impl std::fmt::Display for NumberRangeValidation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Value {} must be between {} and {}",
self.actual,
self.min,
self.max
)
}
}
// Usage
if let Some(err) = errors.age().number_range_validation() {
println!("{}", err); // "Value 200 must be between 0 and 150"
}
For internationalized error messages, use es-fluent:
Derive EsFluent on your validators:
use es_fluent::EsFluent;
#[koruma::validator]
#[derive(Clone, Debug, EsFluent)]
pub struct NumberRangeValidation {
min: i32,
max: i32,
#[koruma(value)]
pub actual: i32,
}
Create corresponding Fluent files:
# locales/en/main.ftl
number-range-validation = Value { $actual } must be between { $min } and { $max }
Use to_fluent_string() to get localized messages:
use es_fluent::ToFluentString as _;
if let Some(err) = errors.age().number_range_validation() {
println!("{}", err.to_fluent_string());
}
all() MethodWhen using the all() method to get all failed validators, you can derive KorumaFluentEnum on the generated enum to implement ToFluentString:
use es_fluent::ToFluentString as _;
use koruma::KorumaFluentEnum;
// Derive KorumaFluentEnum on the generated validator enum
// This requires all inner validators to implement ToFluentString
#[derive(KorumaFluentEnum)]
pub enum ItemValueKorumaValidator {
NumberRangeValidation(NumberRangeValidation),
EvenNumberValidation(EvenNumberValidation),
}
// Now you can iterate over all errors
for validator in errors.value().all() {
println!("{}", validator.to_fluent_string());
}
Note: KorumaFluentEnum requires the fluent feature to be enabled and all variant types must implement ToFluentString.