Introduction
koruma is a Rust validation framework built around derive macros and explicit validator types.
Instead of hiding validation logic behind opaque attributes, it lets you define reusable validator
structs, attach them to fields, and derive strongly typed error accessors.
This book is a comprehensive guide to understanding and applying the koruma validation workflow from the ground up:
- define validators with
#[validator], - attach them with
#[koruma(...)], - derive
Korumaon your data type, - and inspect failures through generated typed error accessors.
The next chapter walks through the basic setup and the overall flow before going deeper into custom validators, error handling, localisation, nested validation, and newtypes.
Getting Started
Add koruma to your dependencies:
[dependencies]
koruma = { version = "*" }
Feature flags
derive(default): enables derive/attribute macros (Koruma,KorumaAllDisplay,#[validator]).fluent: enables localized error support forKorumaAllFluent(use withes-fluent).internal-showcase: enables internal validator showcase registry hooks used by workspace demos.
A typical koruma workflow looks like this:
- Define one or more validator types.
- Implement
Validate<T>for each validator. - Attach validators to fields with
#[koruma(...)]. - Derive
Korumaon the struct you want to validate. - Call
validate()and inspect the generated error accessors.
A minimal example:
use koruma::{Koruma, KorumaAllDisplay, Validate, validator};
use std::fmt;
#[validator]
#[derive(Clone, Debug)]
pub struct NonEmptyStringValidation {
#[koruma(value)]
pub input: String,
}
impl Validate<String> for NonEmptyStringValidation {
fn validate(&self, value: &String) -> bool {
!value.is_empty()
}
}
impl fmt::Display for NonEmptyStringValidation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "value must not be empty")
}
}
#[derive(Koruma, KorumaAllDisplay)]
pub struct User {
#[koruma(NonEmptyStringValidation)]
pub username: String,
}
let user = User {
username: "".to_string(),
};
if let Err(errors) = user.validate() {
if let Some(err) = errors.username().non_empty_string_validation() {
println!("{}", err);
}
}
The following chapters expand this pattern and show how to build richer validators and more useful error reporting.
Declaring Validators
Validators are regular Rust types that describe a validation rule. To define one, annotate the
struct with #[validator] and implement Validate<T> for the input type you want to check. The
#[koruma(value)] attribute marks the field that stores the actual input value used for error
reporting.
For example, a generic range validator:
use koruma::{Validate, validator};
use std::fmt;
#[validator]
#[derive(Clone, Debug)]
pub struct NumberRangeValidation<T: PartialOrd + Copy + fmt::Display + Clone> {
min: T,
max: T,
#[koruma(value)]
pub actual: T,
}
impl<T: PartialOrd + Copy + fmt::Display> Validate<T> for NumberRangeValidation<T> {
fn validate(&self, value: &T) -> bool {
*value >= self.min && *value <= self.max
}
}
impl<T: PartialOrd + Copy + fmt::Display + Clone> fmt::Display for NumberRangeValidation<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"value {} must be between {} and {}",
self.actual, self.min, self.max
)
}
}
You can also write type-specific validators. For example, a validator for string length:
use koruma::{Validate, validator};
use std::fmt;
#[validator]
#[derive(Clone, Debug)]
pub struct StringLengthValidation {
min: usize,
max: usize,
#[koruma(value)]
pub input: String,
}
impl Validate<String> for StringLengthValidation {
fn validate(&self, value: &String) -> bool {
let len = value.len();
len >= self.min && len <= self.max
}
}
impl fmt::Display for StringLengthValidation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"string length {} must be between {} and {} characters",
self.input.len(),
self.min,
self.max
)
}
}
The core pattern stays the same: define any configuration fields you need, mark the validated value
with #[koruma(value)], implement Validate<T>, and optionally implement Display for friendly
error messages.
Using Validators
Once validators are defined, attach them to fields with #[koruma(...)] and derive Koruma on
your data type. The derive macro generates a validate() method that runs the configured
validators and returns Result<(), Errors>.
use koruma::{Koruma, KorumaAllDisplay};
#[derive(Koruma, KorumaAllDisplay)]
pub struct Item {
#[koruma(NumberRangeValidation::<_>(min = 0, max = 100))]
pub age: i32,
#[koruma(StringLengthValidation(min = 1, max = 67))]
pub name: String,
// Fields without #[koruma(...)] are ignored by validation.
pub internal_id: u64,
}
let item = Item {
age: 150,
name: "".to_string(),
internal_id: 1,
};
match item.validate() {
Ok(()) => println!("Item is valid"),
Err(errors) => {
if let Some(age_err) = errors.age().number_range_validation() {
println!("age failed: {}", age_err);
}
if let Some(name_err) = errors.name().string_length_validation() {
println!("name failed: {}", name_err);
}
}
}
Use TypeName::<_>(...) when the validator is generic and Rust can infer the missing type
parameter. If a field has no #[koruma(...)] attribute, koruma does not validate it.
For fields with more than one validator, koruma generates accessors for each validator as well as
an all() iterator when you derive KorumaAllDisplay or KorumaAllFluent. The next chapter
covers that multi-validator case in more detail.
Multiple Validators & Error Handling
A field can have more than one validator. After deriving Koruma, calling validate() runs all
configured validators and returns Result<(), Errors>. On failure, the generated Errors type
provides strongly typed field accessors, and each field error group exposes accessors for the
individual validators that failed.
if let Err(errors) = item.validate() {
for failed in errors.age().all() {
println!("age validator: {}", failed);
}
for failed in errors.name().all() {
println!("name validator: {}", failed);
}
}
This pattern is useful when a field has multiple rules and you want to show every failure instead of
only the first one. The order of execution follows the order in which validators are listed in the
#[koruma(...)] attribute, and all configured validators are evaluated.
You can customise error rendering by implementing Display for validators, or localise errors with
es-fluent and KorumaAllFluent, which is covered in the next chapter.
i18n Integration with es-fluent
koruma can integrate with es-fluent to localise validation errors. Enable the fluent feature
and add the matching es-fluent dependency:
[dependencies]
koruma = { version = "*", features = ["derive", "fluent"] }
es-fluent = { version = "*", features = ["derive"] }
This setup assumes:
korumais built withderive+fluent.- your
es-fluentmanager is initialized. - a locale is selected before rendering messages.
Validators intended for localisation derive EsFluent. When the validated value needs custom
conversion, annotate it with #[fluent(value(|x| ...))]. Then derive KorumaAllFluent on the
consumer type.
use es_fluent::{EsFluent, ToFluentString as _};
use koruma::{Koruma, KorumaAllFluent, Validate, validator};
#[validator]
#[derive(Clone, Debug, EsFluent)]
pub struct IsEvenNumberValidation<
T: Clone + Copy + std::fmt::Display + std::ops::Rem<Output = T> + From<u8> + PartialEq,
> {
#[koruma(value)]
#[fluent(value(|x: &T| x.to_string()))]
pub actual: T,
}
impl<T: Copy + std::fmt::Display + std::ops::Rem<Output = T> + From<u8> + PartialEq> Validate<T>
for IsEvenNumberValidation<T>
{
fn validate(&self, value: &T) -> bool {
*value % T::from(2u8) == T::from(0u8)
}
}
#[validator]
#[derive(Clone, Debug, EsFluent)]
pub struct NonEmptyStringValidation {
#[koruma(value)]
pub input: String,
}
impl Validate<String> for NonEmptyStringValidation {
fn validate(&self, value: &String) -> bool {
!value.is_empty()
}
}
#[derive(Koruma, KorumaAllFluent)]
pub struct User {
#[koruma(IsEvenNumberValidation::<_>)]
pub id: i32,
#[koruma(NonEmptyStringValidation)]
pub username: String,
}
let user = User { id: 3, username: "".to_string() };
if let Err(errors) = user.validate() {
if let Some(id_err) = errors.id().is_even_number_validation() {
println!("{}", id_err.to_fluent_string());
}
if let Some(username_err) = errors.username().non_empty_string_validation() {
println!("{}", username_err.to_fluent_string());
}
for failed in errors.id().all() {
println!("{}", failed.to_fluent_string());
}
for failed in errors.username().all() {
println!("{}", failed.to_fluent_string());
}
}
KorumaAllFluent gives you an all() iterator whose elements can be converted with
ToFluentString. Initialise your i18n manager and select a locale before rendering localised
messages.
Nested Validation
When your data model contains structs inside other structs, you can use the #[koruma(nested)] attribute to validate them hierarchically.
This attribute tells koruma to call validate() on the nested field and include its errors in the parent’s error type if any occur. This allows the parent struct’s Errors struct to provide strongly typed access not just to its own fields, but also to the nested struct’s fields.
use koruma::{Koruma, KorumaAllDisplay};
#[derive(Clone, Koruma)]
pub struct Address {
#[koruma(StringLengthValidation(min = 1, max = 100))]
pub street: String,
#[koruma(StringLengthValidation(min = 1, max = 50))]
pub city: String,
// Imagine ZipCodeValidation is defined somewhere
#[koruma(ZipCodeValidation)]
pub zip_code: String,
}
#[derive(Koruma)]
pub struct Customer {
#[koruma(StringLengthValidation(min = 1, max = 100))]
pub name: String,
// Nested struct - validation cascades automatically
#[koruma(nested)]
pub address: Address,
}
let customer = Customer {
name: "".to_string(), // Invalid: empty name
address: Address {
street: "123 Main St".to_string(),
city: "".to_string(), // Invalid: empty city
zip_code: "ABC".to_string(), // Invalid: not 5 digits
},
};
match customer.validate() {
Ok(()) => println!("Customer is valid!"),
Err(errors) => {
// Access top-level field errors
if let Some(name_err) = errors.name().string_length_validation() {
println!("name: {}", name_err);
}
// Access nested struct errors
if let Some(address_err) = errors.address() {
if let Some(street_err) = address_err.street().string_length_validation() {
println!("street: {}", street_err);
}
if let Some(city_err) = address_err.city().string_length_validation() {
println!("city: {}", city_err);
}
if let Some(zip_err) = address_err.zip_code().zip_code_validation() {
println!("zip_code: {}", zip_err);
}
}
}
}
How It Works
- Both the parent (
Customer) and nested (Address) structs must deriveKoruma. - When
customer.validate()is called, it verifiesnamenormally and also callsaddress.validate(). - If
address.validate()fails, the resulting errors are wrapped insidecustomer’s overallErrorsstruct. - You access the nested errors using the corresponding accessor (
errors.address()), which returns anOption<AddressErrors>. If there are any errors in theaddress, this returnsSome, containing the exact error tree of the nested type.
This nested pattern seamlessly integrates with all koruma features including es-fluent localisation and newtype validation.
Newtype Pattern & TryFrom
Use #[koruma(try_new, newtype(try_from))] when you need:
try_new- a checked constructor function (fn try_new(value: Inner) -> Result<Self, Error>)newtype(try_from)- aTryFrom<Inner>impl forFrom/try_fromcallsnewtype- transparent error access viaDerefto the inner field’s error
You can layer derive_more traits on top for additional wrapper ergonomics (for example, Deref
to inner value).
use es_fluent::ToFluentString as _;
use koruma::{Koruma, KorumaAllFluent, Validate};
#[derive(Clone, Koruma, KorumaAllFluent)]
#[koruma(try_new, newtype)]
pub struct Email {
#[koruma(NonEmptyStringValidation)]
pub value: String,
}
#[derive(Koruma, KorumaAllFluent)]
pub struct SignupForm {
#[koruma(NonEmptyStringValidation)]
pub username: String,
#[koruma(newtype)]
pub email: Email,
}
let form = SignupForm {
username: "".to_string(),
email: Email {
value: "".to_string(),
},
};
if let Err(errors) = form.validate() {
if let Some(username_err) = errors.username().non_empty_string_validation() {
println!("username failed: {}", username_err.to_fluent_string());
}
if let Some(email_err) = errors.email().non_empty_string_validation() {
println!("email failed: {}", email_err.to_fluent_string());
}
for failed in errors.email().all() {
println!("email validator: {}", failed.to_fluent_string());
}
}
if let Err(errors) = Email::try_new("".to_string()) {
if let Some(email_err) = errors.non_empty_string_validation() {
println!("email::try_new failed: {}", email_err.to_fluent_string());
}
for failed in errors.all() {
println!("email::try_new validator: {}", failed.to_fluent_string());
}
}
Unnamed newtype (tuple struct)
The same pattern works with tuple structs:
use es_fluent::ToFluentString as _;
use koruma::{Koruma, KorumaAllFluent, Validate};
#[derive(Clone, Koruma, KorumaAllFluent)]
#[koruma(try_new, newtype)]
pub struct Username(#[koruma(NonEmptyStringValidation)] pub String);
#[derive(Koruma, KorumaAllFluent)]
pub struct LoginForm {
#[koruma(newtype)]
pub username: Username,
}
let login = LoginForm {
username: Username("".to_string()),
};
if let Err(errors) = login.validate() {
if let Some(username_err) = errors.username().non_empty_string_validation() {
println!("username failed: {}", username_err.to_fluent_string());
}
}
if let Ok(username) = Username::try_new("alice".to_string()) {
println!("username created: {}", username.0);
}
TryFrom integration (#[koruma(newtype(try_from))])
Add try_from inside newtype(...) to generate a TryFrom<Inner> impl:
use std::convert::TryFrom;
use es_fluent::ToFluentString as _;
use koruma::{Koruma, KorumaAllFluent, Validate};
#[derive(Clone, Koruma, KorumaAllFluent)]
#[koruma(newtype(try_from))]
pub struct Only67u8(#[koruma(Only67Validation::<_>)] u8);
match Only67u8::try_from(69) {
Ok(n) => println!("{}!", n.0),
Err(errors) => {
for failed in errors.all() {
println!("validation failed: {}", failed.to_fluent_string());
}
}
}
koruma-collection
A curated set of validators built on top of koruma, organized by domain.
Installation
[dependencies]
koruma-collection = { version = "*", features = ["full"] }
Modules at a glance
use koruma_collection::{collection, format, general, numeric, string};
Feature flags
fmt(default):Displaymessages for validators.full: enables optional validator dependencies (url,credit-card,phone-number,email,regex,smallvec).fluent: enables i18n integration with es-fluent.full-fluent:full+fluent.
Validator-specific optional flags:
credit-cardforformat::CreditCardValidationemailforformat::EmailValidationphone-numberforformat::PhoneNumberValidationurlforformat::UrlValidationregexforstring::PatternValidationsmallvecforcollection::HasLensupport onSmallVec
Complete validator catalog
String validators (koruma_collection::string)
| Validator | Rule | Example attribute | Feature |
|---|---|---|---|
AlphanumericValidation<T> | Only letters and numbers | #[koruma(string::AlphanumericValidation::<_>)] | always |
AsciiValidation<T> | ASCII-only input | #[koruma(string::AsciiValidation::<_>)] | always |
ContainsValidation<T> | Contains substring | #[koruma(string::ContainsValidation::<_>(substring = "abc"))] | always |
MatchesValidation<T> | Equals expected value | #[koruma(string::MatchesValidation::<_>(other = "secret".to_string()))] | always |
PatternValidation<T> | Matches regex pattern | #[koruma(string::PatternValidation::<_>(pattern = r"^[a-z0-9_]+$"))] | regex |
PrefixValidation<T> | Starts with prefix | #[koruma(string::PrefixValidation::<_>(prefix = "usr_"))] | always |
SuffixValidation<T> | Ends with suffix | #[koruma(string::SuffixValidation::<_>(suffix = ".rs"))] | always |
Format validators (koruma_collection::format)
| Validator | Rule | Example attribute | Feature |
|---|---|---|---|
IpValidation<T> | Valid IP (Any, V4, V6) | #[koruma(format::IpValidation::<_>(kind = format::IpKind::V4))] | always |
EmailValidation<T> | Valid email address | #[koruma(format::EmailValidation::<_>)] | email |
PhoneNumberValidation<T> | Valid phone number | #[koruma(format::PhoneNumberValidation::<_>)] | phone-number |
UrlValidation<T> | Valid URL | #[koruma(format::UrlValidation::<_>)] | url |
CreditCardValidation<T> | Valid credit card number | #[koruma(format::CreditCardValidation::<_>)] | credit-card |
Numeric validators (koruma_collection::numeric)
| Validator | Rule | Example attribute | Feature |
|---|---|---|---|
PositiveValidation<T> | value > 0 | #[koruma(numeric::PositiveValidation::<_>)] | always |
NonNegativeValidation<T> | value >= 0 | #[koruma(numeric::NonNegativeValidation::<_>)] | always |
NonPositiveValidation<T> | value <= 0 | #[koruma(numeric::NonPositiveValidation::<_>)] | always |
NegativeValidation<T> | value < 0 | #[koruma(numeric::NegativeValidation::<_>)] | always |
RangeValidation<T> | Between min and max (inclusive by default) | #[koruma(numeric::RangeValidation::<_>(min = 0, max = 100, exclusive_max = true))] | always |
Collection validators (koruma_collection::collection)
| Validator | Rule | Example attribute | Feature |
|---|---|---|---|
LenValidation<T> | Length within [min, max] | #[koruma(collection::LenValidation::<_>(min = 1, max = 10))] | always |
NonEmptyValidation<T> | Collection/string is not empty | #[koruma(collection::NonEmptyValidation::<_>)] | always |
collection::HasLen is implemented for common standard types (String, str,
arrays/slices, Vec, sets/maps, etc.) and optionally for SmallVec with the
smallvec feature.
General validators (koruma_collection::general)
| Validator | Rule | Example attribute | Feature |
|---|---|---|---|
RequiredValidation<Option<T>> | Option must be Some | #[koruma(general::RequiredValidation::<Option<_>>)] | always |
Example
use koruma::{Koruma, KorumaAllDisplay};
use koruma_collection::{collection, general, numeric, string};
#[derive(Koruma, KorumaAllDisplay)]
struct SignupInput {
#[koruma(collection::NonEmptyValidation::<_>)]
username: String,
#[koruma(string::AsciiValidation::<_>, string::AlphanumericValidation::<_>)]
handle: String,
#[koruma(numeric::RangeValidation::<_>(min = 13_u8, max = 120_u8))]
age: u8,
#[koruma(general::RequiredValidation::<Option<_>>)]
display_name: Option<String>,
}
let input = SignupInput {
username: "".to_string(),
handle: "bad-handle".to_string(),
age: 8,
display_name: None,
};
if let Err(errors) = input.validate() {
if let Some(err) = errors.username().non_empty_validation() {
println!("username: {err}");
}
if let Some(err) = errors.handle().ascii_validation() {
println!("handle(ascii): {err}");
}
for err in errors.handle().all() {
println!("handle(any): {err}");
}
}