2 unstable releases
Uses new Rust 2024
| new 0.2.0 | Dec 22, 2025 |
|---|---|
| 0.1.0 | Dec 22, 2025 |
#6 in #domain-validation
270KB
3.5K
SLoC
Stilltypes
Domain-specific refined types for Rust
Stilltypes provides production-ready domain predicates and refined types that integrate seamlessly with stillwater. Validate emails, URLs, phone numbers, and more with errors that accumulate and types that prove validity.
Quick Start
use stilltypes::prelude::*;
// Types validate on construction
let email = Email::new("user@example.com".to_string())?;
let url = SecureUrl::new("https://example.com".to_string())?;
// Invalid values fail with helpful errors
let bad = Email::new("invalid".to_string());
assert!(bad.is_err());
println!("{}", bad.unwrap_err());
// invalid email address: invalid format, expected local@domain (example: user@example.com)
Features
Enable only what you need:
[dependencies]
stilltypes = { version = "0.1", default-features = false, features = ["email", "url"] }
| Feature | Types | Dependencies |
|---|---|---|
email (default) |
Email |
email_address |
url (default) |
Url, HttpUrl, SecureUrl |
url |
uuid |
Uuid, UuidV4, UuidV7 |
uuid |
phone |
PhoneNumber |
phonenumber |
financial |
Iban, CreditCardNumber |
iban_validate, creditcard |
network |
Ipv4Addr, Ipv6Addr, DomainName, Port |
- |
geo |
Latitude, Longitude |
- |
numeric |
Percentage, UnitInterval |
- |
identifiers |
Slug |
- |
serde |
Serialize/Deserialize for all types | - |
full |
All of the above | - |
Error Accumulation
Collect all validation errors at once using stillwater's Validation:
use stilltypes::prelude::*;
use stillwater::validation::Validation;
struct ValidForm {
email: Email,
phone: PhoneNumber,
}
fn validate_form(email: String, phone: String) -> Validation<ValidForm, Vec<DomainError>> {
Validation::all((
Email::new(email).map_err(|e| vec![e]),
PhoneNumber::new(phone).map_err(|e| vec![e]),
))
.map(|(email, phone)| ValidForm { email, phone })
}
match validate_form(email, phone) {
Validation::Success(form) => handle_valid(form),
Validation::Failure(errors) => {
for err in errors {
println!("Error: {}", err);
}
}
}
JSON Validation
With the serde feature, types validate during deserialization:
use stilltypes::prelude::*;
use serde::Deserialize;
#[derive(Deserialize)]
struct User {
email: Email,
website: Option<SecureUrl>,
}
// Invalid JSON fails to deserialize
let result: Result<User, _> = serde_json::from_str(json);
Available Domain Types
Email (RFC 5321)
use stilltypes::email::Email;
let email = Email::new("user@example.com".to_string())?;
assert_eq!(email.get(), "user@example.com");
// Plus addressing works
let plus = Email::new("user+tag@example.com".to_string())?;
URL (RFC 3986)
use stilltypes::url::{Url, HttpUrl, SecureUrl};
// Any valid URL
let any_url = Url::new("ftp://files.example.com".to_string())?;
// HTTP or HTTPS only
let http = HttpUrl::new("http://example.com".to_string())?;
// HTTPS only (secure)
let secure = SecureUrl::new("https://secure.example.com".to_string())?;
let insecure = SecureUrl::new("http://example.com".to_string());
assert!(insecure.is_err()); // HTTP rejected
UUID
use stilltypes::uuid::{Uuid, UuidV4, UuidV7, ToUuid};
// Any valid UUID
let any = Uuid::new("550e8400-e29b-41d4-a716-446655440000".to_string())?;
// Version-specific
let v4 = UuidV4::new("550e8400-e29b-41d4-a716-446655440000".to_string())?;
let v7 = UuidV7::new("018f6b8e-e4a0-7000-8000-000000000000".to_string())?;
// Convert to uuid::Uuid
let uuid_impl = v4.to_uuid();
assert_eq!(uuid_impl.get_version_num(), 4);
Phone Numbers (E.164)
use stilltypes::phone::{PhoneNumber, PhoneNumberExt};
let phone = PhoneNumber::new("+1 (415) 555-1234".to_string())?;
// Normalize to E.164 for storage
assert_eq!(phone.to_e164(), "+14155551234");
// Get country code
assert_eq!(phone.country_code(), 1);
Financial
use stilltypes::financial::{Iban, CreditCardNumber, IbanExt, CreditCardExt};
// IBAN validation
let iban = Iban::new("DE89370400440532013000".to_string())?;
assert_eq!(iban.country_code(), "DE");
assert_eq!(iban.masked(), "DE89****3000"); // For display
// Credit card validation (Luhn algorithm)
let card = CreditCardNumber::new("4111111111111111".to_string())?;
assert_eq!(card.masked(), "****1111"); // For display
assert_eq!(card.last_four(), "1111");
Network (IP, Domain, Port)
use stilltypes::network::{Ipv4Addr, Ipv6Addr, Port, DomainName, Ipv4Ext, PortExt};
// IPv4 validation with semantic helpers
let ip = Ipv4Addr::new("192.168.1.1".to_string())?;
assert!(ip.is_private());
assert!(!ip.is_loopback());
// IPv6 validation
let ipv6 = Ipv6Addr::new("::1".to_string())?;
assert!(ipv6.is_loopback());
// Port validation with IANA range classification
let port = Port::new(443)?;
assert!(port.is_privileged());
assert!(port.is_well_known());
// Domain name validation (RFC 1035)
let domain = DomainName::new("api.example.com".to_string())?;
assert_eq!(domain.tld(), Some("com"));
Geographic Coordinates
use stilltypes::geo::{Latitude, Longitude, LatitudeExt, LongitudeExt};
// Latitude validates range -90 to 90 degrees
let lat = Latitude::new(37.7749)?;
assert!(lat.is_north());
// Longitude validates range -180 to 180 degrees
let lon = Longitude::new(-122.4194)?;
assert!(lon.is_west());
// Convert to degrees, minutes, seconds
let (deg, min, sec, hemi) = lat.to_dms();
// 37° 46' 29.64" N
Bounded Numerics
use stilltypes::numeric::{Percentage, UnitInterval, PercentageExt, UnitIntervalExt};
// Percentage validates range 0 to 100
let discount = Percentage::new(25.0)?;
let price = 100.0;
let discounted = price - discount.of(price); // 75.0
// Convert between representations
let probability = UnitInterval::new(0.75)?;
let as_percent = probability.to_percentage(); // 75%
// Create from decimal
let half = Percentage::from_decimal(0.5)?; // 50%
URL Slugs
use stilltypes::identifiers::{Slug, SlugExt};
// Validate existing slug
let slug = Slug::new("my-first-post".to_string())?;
assert_eq!(slug.get(), "my-first-post");
// Convert from title
let slug = Slug::from_title("My First Blog Post!")?;
assert_eq!(slug.get(), "my-first-blog-post");
// Error on invalid slugs
let invalid = Slug::new("Invalid Slug".to_string());
assert!(invalid.is_err());
When to Use Stilltypes
Use Stilltypes when:
- Validating forms with multiple fields (accumulate all errors)
- Building APIs that need comprehensive input validation
- You want type-level guarantees throughout your codebase
- Working with the Stillwater ecosystem
Skip Stilltypes if:
- Validating a single field in a simple script
- Your domain already has validation (e.g., ORM validates emails)
- You only need one domain type (just copy the predicate)
Philosophy
Stilltypes follows the Stillwater philosophy:
- Pragmatism Over Purity - No unnecessary abstractions; just predicates
- Parse, Don't Validate - Domain types encode invariants in the type
- Composition Over Complexity - Uses stillwater's
And,Or,Not - Errors Should Tell Stories - Rich context for user-facing messages
Examples
See the examples/ directory for complete working examples:
form_validation.rs- Error accumulation withValidation::all()api_handler.rs- Effect composition withfrom_validation()network_validation.rs- Server config validation with IP/port/domaingeo_validation.rs- Geographic coordinate validation with DMS conversiondiscount_validation.rs- Percentage and pricing calculations with numeric typesslug_validation.rs- URL slug validation and title conversion
Run with:
cargo run --example form_validation --features full
cargo run --example api_handler --features full
cargo run --example network_validation --features full
cargo run --example geo_validation --features full
cargo run --example discount_validation --features full
cargo run --example slug_validation --features full
The Stillwater Ecosystem
| Library | Purpose |
|---|---|
| stillwater | Effect composition and validation core |
| stilltypes | Domain-specific refined types |
| mindset | Zero-cost state machines |
| premortem | Configuration validation |
| postmortem | JSON validation with path tracking |
License
Licensed under the MIT license. See LICENSE for details.
Dependencies
~4–10MB
~113K SLoC