Validation and Refinement — Constrained Types

Schemas parse structure. Validation adds constraints: an age must be positive, an email must contain @, a price must have at most two decimal places. Refinement goes further: a validated Email is a different type from a raw String, so you can never accidentally pass an unvalidated string where an email is expected.

refine: Attach a Predicate

refine takes a schema and a predicate. Parsing succeeds only if both the schema's parse and the predicate pass:

#![allow(unused)]
fn main() {
use id_effect::schema::{string, i64, refine};

// Age must be between 0 and 150
let age_schema = refine(
    i64(),
    |n| (0..=150).contains(n),
    "age must be between 0 and 150",
);

// Non-empty string
let non_empty = refine(
    string(),
    |s: &String| !s.is_empty(),
    "must not be empty",
);
}

If the predicate returns false, parsing fails with a ParseError containing the message you provided.

filter: Same as refine, Different Style

filter is an alias for refine with a closure-first signature, matching Rust iterator conventions:

#![allow(unused)]
fn main() {
let positive = i64().filter(|n| *n > 0, "must be positive");
let trimmed  = string().filter(|s| s == s.trim(), "must not have leading/trailing whitespace");
}

Use whichever reads more naturally.

try_map: Fallible Transformation

When conversion logic can fail — parsing a date, constructing a URL, validating an email — use .try_map:

#![allow(unused)]
fn main() {
use id_effect::schema::ParseError;

let url_schema = string().try_map(|s| {
    url::Url::parse(&s).map_err(|e| ParseError::custom(format!("invalid URL: {e}")))
});

let date_schema = string().try_map(|s| {
    chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
        .map_err(|e| ParseError::custom(format!("invalid date: {e}")))
});
}

.try_map runs after the base schema succeeds. The closure returns Result<NewType, ParseError>.

Brand — Newtypes with Zero Cost

A Brand is a newtype wrapper that exists only at the type level. At runtime it's transparent. At compile time it prevents mixing up bare primitives with domain values:

#![allow(unused)]
fn main() {
use id_effect::schema::Brand;

// Define branded types
type UserId   = Brand<i64,    UserIdMarker>;
type Email    = Brand<String, EmailMarker>;
type PosPrice = Brand<f64,    PosPriceMarker>;

struct UserIdMarker;
struct EmailMarker;
struct PosPriceMarker;
}

Build schemas that produce branded types:

#![allow(unused)]
fn main() {
let user_id_schema: Schema<UserId> = i64()
    .filter(|n| *n > 0, "user id must be positive")
    .map(Brand::new);

let email_schema: Schema<Email> = string()
    .try_map(|s| {
        if s.contains('@') {
            Ok(Brand::new(s))
        } else {
            Err(ParseError::custom("invalid email"))
        }
    });
}

Now functions that need an Email won't compile with a bare String:

#![allow(unused)]
fn main() {
fn send_welcome(to: Email) -> Effect<(), MailError, Mailer> { /* … */ }

// This compiles:
send_welcome(parsed_email);

// This doesn't:
send_welcome("alice@example.com".to_string()); // type error: expected Email, found String
}

HasSchema — Attaching Schemas to Types

When a type always has the same schema, implement HasSchema:

#![allow(unused)]
fn main() {
use id_effect::schema::HasSchema;

impl HasSchema for User {
    fn schema() -> Schema<Self> {
        struct_!(User {
            id:    user_id_schema(),
            email: email_schema(),
            name:  non_empty_string_schema(),
        })
    }
}

// Now parse using the impl
let user: User = User::schema().run(raw)?;
}

HasSchema types work with generic tooling (exporters, documentation generators, UI scaffolding) that needs to know a type's schema without being parameterised over it.

Summary

ToolWhen to use
refine / filterPredicate on a successfully-parsed value
try_mapFallible conversion after parse
BrandNewtypes that prevent mixing domain values
HasSchemaAttach the canonical schema to a type