04
Product
16
Backend
09
Auth
12
iOS
07
Infra
02
Real-Time

Use parse-don't-validate for type safety

ADR-0043 ACCEPTED · 2026-01-16
Use Parse Don't Validate Pattern for Type Safety

Context

Without a consistent validation approach, validation logic scatters throughout the codebase:

  • Functions repeatedly validate the same inputs
  • Validation failures occur deep in business logic rather than at boundaries
  • Types don't encode their invariants, requiring defensive checks everywhere
  • API signatures using primitives don't communicate requirements

Traditional validation approaches check data wherever it's used, creating maintenance burden and unclear APIs:

fn process_user(email: String) -> Result<()> {
    if !is_valid_email(&email) { return Err(...) }
    // ...
}

fn send_notification(email: String) -> Result<()> {
    if !is_valid_email(&email) { return Err(...) }  // Duplicated
    // ...
}

Decision

Validate inputs once at system boundaries by parsing into types that encode invariants, then work exclusively with those validated types.

Core Approach

Parse at Boundaries: Validation happens at system edges (API endpoints, database reads, external services) through parsing that returns Result<ValidatedType, Error>.

Private Fields + Smart Constructors: Types use private fields preventing direct construction, exposing only validated constructors that enforce invariants.

Type-Encoded Guarantees: Once parsed, functions accepting validated types don't need defensive checks. The type system guarantees invariants hold.

Examples from Codebase

Newtype for simple invariants:

// src/domain/accommodation/types.rs:52-78
pub struct PlaceId(String);  // Guarantees non-empty, trimmed string

impl PlaceId {
    pub fn parse(s: &str) -> Result<Self, AccommodationError> {
        let trimmed = s.trim();
        if trimmed.is_empty() { return Err(AccommodationError::InvalidPlaceId); }
        Ok(PlaceId(trimmed.to_string()))
    }
}

Business rule enforcement:

// src/domain/accommodation/types.rs:7-50
pub struct AccommodationDates {
    check_in: NaiveDate,
    check_out: NaiveDate,
}

impl AccommodationDates {
    pub fn new(check_in: NaiveDate, check_out: NaiveDate) -> Result<Self, Error> {
        if check_out <= check_in { return Err(Error::InvalidDateRange); }
        Ok(Self { check_in, check_out })  // Guarantees check_out > check_in
    }
}

Boundary parsing in GraphQL:

// src/domain/accommodation/graphql.rs:75-120
async fn apply_accommodation(...) -> Result<Trip> {
    let dates = AccommodationDates::parse(&data.check_in, &data.check_out)
        .into_graphql_result()?;  // Parse at API boundary

    service.add_accommodation(dates, ...)  // Work with validated type
}

Additional examples: ValidatedToken, AuthUser, Effect enum with serde validation.

Consequences

Positive

Type Safety: Function signatures communicate requirements. fn send_email(to: Email) vs fn send_email(to: String).

Single Source of Truth: Each type's validation exists in one place (its constructor), eliminating duplication.

Early Error Detection: Validation failures happen at boundaries before flowing into business logic.

Alignment with DDD: Supports ubiquitous language (ADR-0009) by encoding domain rules in types.

Negative

Upfront Design: Requires identifying invariants and designing constructors carefully.

Verbosity: Accessing validated data requires unwrapping (e.g., place_id.as_str() vs direct String).

Learning Curve: Developers unfamiliar with type-driven design may find it initially verbose.

Potential Over-Engineering: Not every string needs a newtype. Apply judiciously at boundaries and for meaningful invariants.