Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

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 Koruma on 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 for KorumaAllFluent (use with es-fluent).
  • internal-showcase: enables internal validator showcase registry hooks used by workspace demos.

A typical koruma workflow looks like this:

  1. Define one or more validator types.
  2. Implement Validate<T> for each validator.
  3. Attach validators to fields with #[koruma(...)].
  4. Derive Koruma on the struct you want to validate.
  5. 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:

  • koruma is built with derive + fluent.
  • your es-fluent manager 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

  1. Both the parent (Customer) and nested (Address) structs must derive Koruma.
  2. When customer.validate() is called, it verifies name normally and also calls address.validate().
  3. If address.validate() fails, the resulting errors are wrapped inside customer’s overall Errors struct.
  4. You access the nested errors using the corresponding accessor (errors.address()), which returns an Option<AddressErrors>. If there are any errors in the address, this returns Some, 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) - a TryFrom<Inner> impl for From/try_from calls
  • newtype - transparent error access via Deref to 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): Display messages 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-card for format::CreditCardValidation
  • email for format::EmailValidation
  • phone-number for format::PhoneNumberValidation
  • url for format::UrlValidation
  • regex for string::PatternValidation
  • smallvec for collection::HasLen support on SmallVec

Complete validator catalog

String validators (koruma_collection::string)

ValidatorRuleExample attributeFeature
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)

ValidatorRuleExample attributeFeature
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)

ValidatorRuleExample attributeFeature
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)

ValidatorRuleExample attributeFeature
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)

ValidatorRuleExample attributeFeature
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}");
    }
}