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.
refine / filter
refine and filter attach a predicate to an existing schema. Parsing succeeds only when the base schema succeeds and the predicate returns true.
use effectful::schema::{i64, refine, string, filter};
let age_schema = refine(
i64::<()>(),
|n| (0..=150).contains(n),
"age must be between 0 and 150",
);
let non_empty = filter(
string::<()>(),
|s: &String| !s.is_empty(),
"must not be empty",
);
If the predicate fails, decoding returns ParseError::new("", message).
Fallible Transformation
Use transform when conversion can fail or when the semantic type differs from the wire type.
use effectful::schema::{ParseError, string, transform};
let url_schema = transform(
string::<()>(),
|s| url::Url::parse(&s).map_err(|e| ParseError::new("", format!("invalid URL: {e}"))),
|url: url::Url| url.to_string(),
);
The decode closure returns Result<B, ParseError>. The encode closure maps the semantic value back to the base schema’s semantic type.
Brand
Brand<A, B> is a zero-cost nominal wrapper. Use Brand::nominal when the value was already validated, or RefinedBrand when construction should validate.
use effectful::schema::{Brand, RefinedBrand};
struct EmailMarker;
type Email = Brand<String, EmailMarker>;
let email = Brand::nominal("alice@example.com".to_string());
let make_email = RefinedBrand::<String, EmailMarker>::new(|s| {
if s.contains('@') {
Ok(())
} else {
Err("invalid email".to_string())
}
});
let checked: Email = make_email.try_make("alice@example.com".to_string())?;
Now APIs can demand Email instead of a raw String.
fn send_welcome(to: Email) -> Effect<(), MailError, Mailer> { /* ... */ }
HasSchema
HasSchema attaches a canonical schema to a type family. The trait exposes associated types for semantic value, wire value, and schema marker.
use effectful::schema::{HasSchema, Schema, i64};
struct UserIdSchema;
impl HasSchema for UserIdSchema {
type A = i64;
type I = i64;
type E = ();
fn schema() -> Schema<Self::A, Self::I, Self::E> {
i64::<()>()
}
}
Use HasSchema for generic tooling that needs to ask for a canonical schema without knowing how it is built.
Summary
| Tool | When to use |
|---|---|
refine / filter | Predicate on a parsed value |
transform | Fallible conversion or semantic/wire conversion |
Brand::nominal | Nominal wrapper after validation elsewhere |
RefinedBrand | Validating branded constructor |
HasSchema | Attach a canonical schema to a type-level provider |