Domain types that prove validity - validate once, trust everywhere
Links: GitHub | Crates.io | Documentation
Overview#
Stilltypes provides production-ready refined types for common domain concepts. Validate emails, URLs, phone numbers, IBANs, and more with types that prove validity at construction time. Once you have an Email, you know it’s valid - no re-validation needed throughout your codebase.
The Problem#
Domain validation in Rust often suffers from:
- Stringly-typed code - Passing
Stringeverywhere means any function could receive invalid data - Repeated validation - The same validation logic scattered across endpoints, services, and models
- Poor error messages - Generic “invalid input” errors that don’t help users fix their data
- Validation drift - Different parts of the codebase enforcing different rules for the same concept
- Missing edge cases - Hand-rolled regex missing RFC edge cases for emails, URLs, phone numbers
The Solution#
Stilltypes provides refined types that validate on construction and carry proof of validity in the type:
use stilltypes::prelude::*;
// Types validate on construction
let email = Email::new("[email protected]".to_string())?;
let url = SecureUrl::new("https://example.com".to_string())?;
let phone = PhoneNumber::new("+1 415 555 1234".to_string())?;
// Invalid values fail with helpful errors
let bad = Email::new("invalid".to_string());
// Error: invalid email address: invalid format, expected local@domain
Once constructed, these types flow through your codebase with compile-time guarantees.
Key Features#
Modular Dependencies#
Enable only what you need - each domain type has its own feature flag:
[dependencies]
stilltypes = { version = "0.2", default-features = false, features = ["email", "url"] }
| Feature | Types | External Deps |
|---|---|---|
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, Port, DomainName | - |
geo | Latitude, Longitude | - |
numeric | Percentage, UnitInterval | - |
identifiers | Slug | - |
Helpful Error Messages#
Errors tell users how to fix their input:
let result = Email::new("missing-at-sign".to_string());
// Error: invalid email address: invalid format, expected local@domain (example: [email protected])
let result = Port::new(70000);
// Error: port must be between 0 and 65535
Semantic Helpers#
Types include domain-appropriate helper methods:
// IP address classification
let ip = Ipv4Addr::new("192.168.1.1".to_string())?;
assert!(ip.is_private());
assert!(!ip.is_loopback());
// Port range classification
let port = Port::new(443)?;
assert!(port.is_privileged());
assert!(port.is_well_known());
// Phone number normalization
let phone = PhoneNumber::new("+1 (415) 555-1234".to_string())?;
assert_eq!(phone.to_e164(), "+14155551234");
// Coordinate conversion
let lat = Latitude::new(37.7749)?;
let (deg, min, sec, hemi) = lat.to_dms(); // 37° 46' 29.64" N
Serde Integration#
With the serde feature, types validate during deserialization:
use stilltypes::prelude::*;
use serde::Deserialize;
#[derive(Deserialize)]
struct UserInput {
email: Email,
website: Option<SecureUrl>,
}
// Invalid JSON fails to deserialize with validation error
let result: Result<UserInput, _> = serde_json::from_str(json);
Financial Types#
Specialized support for financial identifiers:
use stilltypes::financial::{Iban, CreditCardNumber, IbanExt, CreditCardExt};
// IBAN with country code extraction
let iban = Iban::new("DE89370400440532013000".to_string())?;
assert_eq!(iban.country_code(), "DE");
assert_eq!(iban.masked(), "DE89****3000"); // Safe for display
// Credit card with Luhn validation
let card = CreditCardNumber::new("4111111111111111".to_string())?;
assert_eq!(card.masked(), "****1111");
assert_eq!(card.last_four(), "1111");
No Effects Required#
Stilltypes uses stillwater’s Refined<T, P> type internally, but your code doesn’t need to use stillwater’s effect system. Types work with standard Result:
use stilltypes::email::Email;
// Just returns Result<Email, RefinementError>
fn register_user(email_str: &str) -> Result<User, MyError> {
let email = Email::new(email_str.to_string())?; // Standard ? operator
db.insert_user(email.value())
}
Technical Highlights#
- Language: Rust
- Core Pattern: Refined types with predicates
- Validation: RFC-compliant where applicable (emails, URLs, phone numbers)
- Dependencies: Modular - only pull in what you use
- Serde: Optional serialization support
- Ecosystem: Part of the Stillwater family
Installation#
# Default (email + url)
cargo add stilltypes
# Specific features
cargo add stilltypes --features uuid,phone
# Everything
cargo add stilltypes --features full
# With serde support
cargo add stilltypes --features full,serde
Quick Example#
use stilltypes::prelude::*;
// Form validation with type-safe fields
struct ValidatedForm {
email: Email,
website: SecureUrl,
phone: PhoneNumber,
}
fn validate_form(
email: String,
website: String,
phone: String,
) -> Result<ValidatedForm, Vec<String>> {
let mut errors = vec![];
let email = Email::new(email).map_err(|e| errors.push(e.to_string())).ok();
let website = SecureUrl::new(website).map_err(|e| errors.push(e.to_string())).ok();
let phone = PhoneNumber::new(phone).map_err(|e| errors.push(e.to_string())).ok();
if errors.is_empty() {
Ok(ValidatedForm {
email: email.unwrap(),
website: website.unwrap(),
phone: phone.unwrap(),
})
} else {
Err(errors)
}
}
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 |
Project Status#
Active development, available on crates.io. Version 0.2.0 with stable APIs.
Links#
- GitHub: github.com/iepathos/stilltypes
- Documentation: docs.rs/stilltypes
- Crates.io: Available via
cargo add stilltypes