DEV Community

Cover image for Refined Types in Rust: Parse, Don't Validate
Glen Baker
Glen Baker

Posted on • Originally published at entropicdrift.com

Refined Types in Rust: Parse, Don't Validate

Originally published on Entropic Drift


The String Problem

How many times have you seen function signatures like this?

fn send_email(to: String, subject: String, body: String) -> Result<(), Error>;

fn create_user(email: String, username: String, password: String) -> Result<User, Error>;

fn transfer_money(from_iban: String, to_iban: String, amount: f64) -> Result<(), Error>;
Enter fullscreen mode Exit fullscreen mode

Every String in these signatures is misleading. They don't accept any string—they accept specific formats. Pass "not-an-email" to send_email and it will fail. Pass "hello" as an IBAN and your money transfer crashes.

The signature promises one thing; the implementation demands another.

Validation Everywhere

The typical fix is validation at every entry point:

fn send_email(to: String, subject: String, body: String) -> Result<(), Error> {
    if !is_valid_email(&to) {
        return Err(Error::InvalidEmail(to));
    }
    // ... send email
}

fn send_welcome_email(email: String) -> Result<(), Error> {
    if !is_valid_email(&email) {
        return Err(Error::InvalidEmail(email));
    }
    // ... send welcome email
}

fn send_password_reset(email: String) -> Result<(), Error> {
    if !is_valid_email(&email) {
        return Err(Error::InvalidEmail(email));
    }
    // ... send reset email
}
Enter fullscreen mode Exit fullscreen mode

Three functions, three validation checks, same validation logic copy-pasted. And what happens when you add a fourth function? Fifth? What happens when the validation logic needs to change?

You might say: "Just validate at the API boundary." But then internal code passes raw strings around, and you're trusting that somewhere upstream, someone validated. The compiler can't verify that trust.

Parse, Don't Validate

Validation returns a boolean, parsing returns a value.

When you validate, you check if data is correct and then continue using the same data. The knowledge that it's valid exists only in your head (and maybe in a comment).

When you parse, you transform unstructured data into structured data. The knowledge that it's valid is encoded in the type.

// Validation: returns bool, you keep the String
fn is_valid_email(s: &str) -> bool { ... }

// Parsing: returns Email, proof of validity in the type
fn parse_email(s: String) -> Result<Email, EmailError> { ... }
Enter fullscreen mode Exit fullscreen mode

Once you have an Email, you know it's valid. The type proves it.

Refined Types

A refined type wraps a base type with a predicate that must be satisfied:

// Conceptually: Email = String where is_valid_email(s) == true
struct Email(String);

impl Email {
    pub fn new(s: String) -> Result<Email, EmailError> {
        if is_valid_email(&s) {
            Ok(Email(s))
        } else {
            Err(EmailError::InvalidFormat)
        }
    }

    pub fn value(&self) -> &str {
        &self.0
    }
}
Enter fullscreen mode Exit fullscreen mode

The constructor is the only way to create an Email. It validates on construction. After that, you have proof of validity that the compiler enforces.

Now the function signatures accurately reflect their requirements:

fn send_email(to: Email, subject: String, body: String) -> Result<(), Error>;

fn create_user(email: Email, username: Username, password: Password) -> Result<User, Error>;

fn transfer_money(from: Iban, to: Iban, amount: Money) -> Result<(), Error>;
Enter fullscreen mode Exit fullscreen mode

Try passing a raw String to send_email and the compiler stops you. The type system prevents invalid data from entering your domain.

The Practical Benefits

1. Validation Happens Once

// At the API boundary
let email = Email::new(request.email)?;

// Now pass Email through your entire system
// No re-validation needed anywhere
send_welcome_email(email.clone());
subscribe_to_newsletter(&email);
log_user_registration(&email);
Enter fullscreen mode Exit fullscreen mode

Validate at the boundary, trust the type everywhere else.

2. Invalid States Become Unrepresentable

struct User {
    email: Email,      // Can never be invalid
    username: Username, // Can never be invalid
    age: Age,          // Can never be negative
}
Enter fullscreen mode Exit fullscreen mode

You can't construct a User with an invalid email. The type system prevents it. No defensive checks needed inside your domain logic.

3. Function Signatures Document Requirements

// Before: What format does this expect?
fn process_phone(phone: String) -> Result<(), Error>;

// After: Self-documenting
fn process_phone(phone: PhoneNumber) -> Result<(), Error>;
Enter fullscreen mode Exit fullscreen mode

The type is the documentation. No comments needed, no runtime surprises.

4. Refactoring Becomes Safer

Need to change email validation rules? Change the Email::new constructor. Every place that creates emails is affected. The compiler shows you everywhere an Email is used.

impl Email {
    pub fn new(s: String) -> Result<Email, EmailError> {
        // Add new rule: reject emails from blocked domains
        if BLOCKED_DOMAINS.contains(&domain_of(&s)) {
            return Err(EmailError::BlockedDomain);
        }
        // ... rest of validation
    }
}
Enter fullscreen mode Exit fullscreen mode

All call sites that construct Email now enforce the new rule. No grep required.

Building Refined Types with Stillwater

Stillwater provides a Refined<T, P> type that makes building refined types ergonomic:

use stillwater::refined::{Predicate, Refined};

// Define the predicate
struct IsValidEmail;

impl Predicate<String> for IsValidEmail {
    fn test(value: &String) -> bool {
        value.contains('@') && value.len() >= 5
    }

    fn error_message() -> String {
        "invalid email format, expected local@domain".to_string()
    }
}

// The refined type
type Email = Refined<String, IsValidEmail>;

// Usage
let email = Email::new("user@example.com".to_string())?;
println!("{}", email.value());  // Access inner value
Enter fullscreen mode Exit fullscreen mode

The Refined type handles the boilerplate: constructor validation, inner value access, error messages, Debug/Display implementations.

Domain-Specific Types with Stilltypes

Stilltypes provides production-ready refined types for common domains:

use stilltypes::prelude::*;

// Email (RFC 5321 compliant)
let email = Email::new("user@example.com".to_string())?;

// URL with scheme enforcement
let secure_url = SecureUrl::new("https://api.example.com".to_string())?;
let insecure = SecureUrl::new("http://api.example.com".to_string());
assert!(insecure.is_err());  // HTTP rejected

// Phone numbers (E.164)
let phone = PhoneNumber::new("+1 415 555 1234".to_string())?;
assert_eq!(phone.to_e164(), "+14155551234");

// Financial identifiers
let iban = Iban::new("DE89370400440532013000".to_string())?;
assert_eq!(iban.country_code(), "DE");

// Network types
let port = Port::new(443)?;
assert!(port.is_privileged());
Enter fullscreen mode Exit fullscreen mode

These implementations are backed by RFC-compliant validation libraries. PhoneNumber uses Google's libphonenumber. Iban uses proper checksum validation. Email follows RFC 5321.

Composition: Multiple Constraints

Refined types compose. Need a URL that's both valid and uses HTTPS?

use stillwater::refined::{And, Predicate, Refined};

// Individual predicates
struct IsValidUrl;
struct IsHttps;

impl Predicate<String> for IsValidUrl { ... }
impl Predicate<String> for IsHttps { ... }

// Combined predicate
type SecureUrl = Refined<String, And<IsValidUrl, IsHttps>>;
Enter fullscreen mode Exit fullscreen mode

The And combinator requires both predicates to pass. There's also Or and Not.

Serde Integration

With serde support, refined types validate during deserialization:

use stilltypes::prelude::*;
use serde::Deserialize;

#[derive(Deserialize)]
struct CreateUserRequest {
    email: Email,
    website: Option<SecureUrl>,
    phone: PhoneNumber,
}

// Invalid JSON fails to deserialize
let result: Result<CreateUserRequest, _> = serde_json::from_str(json);
Enter fullscreen mode Exit fullscreen mode

Your API automatically rejects invalid data at the parsing layer. No validation code in your handler.

The Boundary Pattern

Where should you create refined types? At the boundary between untrusted and trusted code:

// API Handler - THE BOUNDARY
async fn create_user(Json(input): Json<RawUserInput>) -> Result<Json<User>, ApiError> {
    // Parse raw input into refined types
    let email = Email::new(input.email)
        .map_err(|e| ApiError::Validation(e.to_string()))?;
    let username = Username::new(input.username)
        .map_err(|e| ApiError::Validation(e.to_string()))?;

    // From here on, work with validated types
    let user = user_service.create(email, username).await?;

    Ok(Json(user))
}

// Service layer - TRUSTS THE TYPES
impl UserService {
    // No validation needed - types guarantee validity
    async fn create(&self, email: Email, username: Username) -> Result<User, ServiceError> {
        self.repo.insert(User { email, username }).await
    }
}

// Repository layer - TRUSTS THE TYPES
impl UserRepository {
    async fn insert(&self, user: User) -> Result<User, DbError> {
        // email and username are guaranteed valid
        sqlx::query("INSERT INTO users (email, username) VALUES ($1, $2)")
            .bind(user.email.value())
            .bind(user.username.value())
            .execute(&self.pool)
            .await?;
        Ok(user)
    }
}
Enter fullscreen mode Exit fullscreen mode

Validation happens once at the HTTP handler. Service and repository layers receive pre-validated data. No defensive checks, no redundant validation, no possibility of invalid data sneaking through.

Error Accumulation

What about forms with multiple fields? You want all errors, not just the first. Combine refined types with Stillwater's Validation:

use stilltypes::prelude::*;
use stillwater::Validation;

fn validate_form(input: FormInput) -> Validation<ValidatedForm, Vec<String>> {
    Validation::all((
        Email::new(input.email).map_err(|e| vec![e.to_string()]),
        PhoneNumber::new(input.phone).map_err(|e| vec![e.to_string()]),
        SecureUrl::new(input.website).map_err(|e| vec![e.to_string()]),
    ))
    .map(|(email, phone, website)| ValidatedForm { email, phone, website })
}

// Returns ALL errors, not just the first
match validate_form(input) {
    Validation::Success(form) => process(form),
    Validation::Failure(errors) => {
        // errors: ["invalid email format", "invalid phone number", "URL must use HTTPS"]
    }
}
Enter fullscreen mode Exit fullscreen mode

When Not to Use Refined Types

Refined types add indirection. Use them when the benefit outweighs the cost:

Use refined types for:

  • Data that crosses trust boundaries (API input, file parsing, user input)
  • Domain concepts with invariants (email, phone, money, coordinates)
  • Values passed through multiple layers of your application
  • Anything where invalid data would cause bugs

Skip refined types for:

  • Internal data structures with limited scope
  • Values validated by the database layer anyway
  • Simple scripts where the overhead isn't worth it
  • Prototyping (add them when the design stabilizes)

The Type-Driven Design Mindset

Refined types are part of a larger principle: make invalid states unrepresentable.

Instead of runtime checks scattered throughout code, encode constraints in types. The compiler becomes your validation engine. Invalid programs don't compile.

// Bad: Invalid state representable
struct Order {
    items: Vec<Item>,  // Could be empty!
    status: String,    // Could be anything!
}

// Good: Invalid state unrepresentable
struct Order {
    items: NonEmptyVec<Item>,  // Guaranteed non-empty
    status: OrderStatus,       // Enum with valid states
}
Enter fullscreen mode Exit fullscreen mode

Combined with refined types, you get:

struct Order {
    id: OrderId,              // Validated format
    customer_email: Email,     // Validated email
    shipping: Address,         // Validated address
    items: NonEmptyVec<Item>,  // Non-empty guarantee
    total: Money,             // Non-negative amount
}
Enter fullscreen mode Exit fullscreen mode

Every field carries its own proof of validity. The domain model itself rejects bad data.

Getting Started

Add stilltypes to your project:

cargo add stilltypes --features email,url,phone
Enter fullscreen mode Exit fullscreen mode

Pick one stringly-typed field in your API. Replace it with a refined type.

// Before
fn create_user(email: String) -> Result<User, Error> {
    if !is_valid_email(&email) { return Err(...); }
    // ...
}

// After
fn create_user(email: Email) -> Result<User, Error> {
    // Email is already valid, just use it
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Parse at the boundary. Trust the types inside.


Summary

Approach Validation Location Compiler Help Guarantees
Raw strings Every function None None
Validation at boundary Entry points None Trust-based
Refined types Construction Full Type-enforced

Refined types move validation from scattered runtime checks to centralized construction. The type system carries proof of validity, eliminating entire categories of bugs.

Parse, don't validate. Let your types tell the truth.


Stilltypes provides production-ready refined types for Rust. Stillwater provides the underlying Refined<T, P> abstraction and validation utilities.


Want more content like this? Follow me on Dev.to or subscribe to Entropic Drift for posts on AI-powered development workflows, Rust tooling, and technical debt management.

Top comments (0)