Skip to content

Investigate type-safe FieldFilter and Form for concrete struct validation #88

@elycruz

Description

@elycruz

Summary

FieldFilter and Form are currently hardwired to HashMap<String, Value> / FormData for hydration and validation. Field names are runtime strings, values are the dynamically-typed Value enum, and type correctness is deferred entirely to runtime TypeMismatch violations. In a statically-typed language like Rust, users should get compiler errors when wiring incompatible rules to fields — not runtime failures.
This issue proposes investigating a trait-based design that lets users validate concrete structs directly, restoring compile-time guarantees while preserving the existing HashMap<String, Value> path for dynamic/config-driven and WASM use cases.

Problem

Current FieldFilter (hardwired to HashMap<String, Value>)

FieldFilter::validate (field_filter.rs) accepts &HashMap<String, Value>:

pub fn validate(&self, data: &HashMap<String, Value>) -> Result<(), FormViolations> { ... }
  • Field names are runtime strings — a typo in a field name silently passes.
  • Field values are the dynamically-typed Value enum — a Rule::<Value>::MinLength applied to a Value::I64 produces a runtime TypeMismatch violation instead of a compile error.
  • Rule/value type compatibility is never checked at compile time.

Current Form (hardwired to FormData)

Form::validate (form.rs) delegates to FieldFilter or per-element validation, both operating on FormData (a HashMap<String, Value> newtype):

pub fn validate(&self, data: &FormData) -> Result<(), FormViolations> { ... }
pub fn bind_data(&mut self, data: FormData) -> &mut Self { ... }

The same runtime-only type safety issues apply.

What users expect in Rust

Users should be able to write:

struct UserAddress {
    street: String,
    zip: String,
}

…attach validation rules per field with compile-time type checking, and call a single validate(&user_address) method — with the compiler rejecting rule/type mismatches (e.g., Rule::<i64>::Min on a String field).

Proposed Direction

1. Filterable trait

Introduce a trait in walrs_inputfilter that concrete structs implement:

pub trait Filterable: Sized {
    /// Validate all fields and cross-field rules.
    fn validate(&self) -> Result<(), FormViolations>;
    /// Apply per-field filters, consuming and returning Self.
    fn filter(self) -> Self;
    /// Filter then validate (provided default).
    fn process(self) -> Result<Self, FormViolations> {
        let filtered = self.filter();
        filtered.validate()?;
        Ok(filtered)
    }
}
  • validate(&self) — validates every field via Rule<T>::validate_ref() / Rule<T>::validate() with the correct concrete T per field, then runs cross-field rules. Collects errors into FormViolations keyed by field name.
  • filter(self) — takes ownership, applies per-field Filter<T>::apply(), returns Self. Ownership avoids mem::take hacks on non-Default fields.
  • process(self) — provided default: filter then validate.
    Both manual trait implementation and derive-macro-generated implementations must be supported.

2. Derive macro (#[derive(Filterable)])

A proc-macro crate (walrs_inputfilter_derive) provides #[derive(Filterable)] with field-level attribute annotations:

#[derive(Filterable)]
struct UserAddress {
    #[validate(required, min_length = 3)]
    #[filter(trim)]
    street: String,
    #[validate(required, pattern = r"^\d{5}$")]
    zip: String,
}

Generated code:

  • validate(&self) constructs Rule::Required.and(Rule::MinLength(3)) for street, calls rule.validate_ref(self.street.as_str()), collects violations keyed by "street".
  • filter(self) constructs Filter::<String>::Trim, applies it to self.street, returns Self { street: filtered, ..self }.
  • Multiple #[validate(...)] attributes combine via Rule::and().
  • Multiple #[filter(...)] attributes produce a Filter::Chain(...).
    Supported annotations:
    | #[validate(...)] | Maps to |
    |---|---|
    | required | Rule::Required |
    | min_length = N | Rule::MinLength(N) |
    | max_length = N | Rule::MaxLength(N) |
    | email | Rule::Email |
    | url | Rule::Url |
    | pattern = "regex" | Rule::Pattern("regex".into()) |
    | min = N | Rule::Min(N) |
    | max = N | Rule::Max(N) |
    | custom = "path::to::fn" | Rule::Custom(Arc::new(path::to::fn)) |
    | #[filter(...)] | Maps to |
    |---|---|
    | trim | Filter::Trim |
    | lowercase | Filter::Lowercase |
    | uppercase | Filter::Uppercase |
    | strip_tags | Filter::StripTags |
    | slug | Filter::Slug { max_length: None } |
    | custom = "path::to::fn" | Filter::Custom(Arc::new(path::to::fn)) |
    A builder/config-block API for dynamic rule composition should also be supported if feasible alongside attribute annotations. If supporting both in the initial design is too complex, prioritize attribute annotations only.

3. Cross-field validation on concrete types

CrossFieldRuleType currently accesses fields via HashMap::get with string keys. On concrete structs, cross-field rules should be typed closures over &Self:
Manual override approach (MVP):

impl Filterable for Registration {
    fn validate(&self) -> Result<(), FormViolations> {
        // Call generated per-field checks
        let mut v = self.__filterable_validate_fields()?;
        if self.password != self.confirm {
            v.add_form_violation(
                Violation::new(ViolationType::NotEqual, "Passwords must match")
            );
        }
        if v.is_empty() { Ok(()) } else { Err(v) }
    }
    // filter() uses generated default
}

Struct-level attribute approach (stretch goal):

#[derive(Filterable)]
#[cross_validate(passwords_match)]
struct Registration {
    password: String,
    confirm: String,
}
fn passwords_match(r: &Registration) -> RuleResult {
    if r.password == r.confirm { Ok(()) }
    else { Err(Violation::new(ViolationType::NotEqual, "Passwords must match")) }
}

4. Per-field filtering strategy

Filter<T> enum's apply method takes values by-move (fn apply(&self, value: T) -> T). The generated filter(self) impl applies per-field filters via ownership transfer:

fn filter(self) -> Self {
    let street = Filter::<String>::Trim.apply(self.street);
    Self { street, ..self }
}

This avoids mem::take / Default requirements. Users who need the original struct can clone before calling filter.

5. Form integration with concrete structs

Form::validate and Form::bind_data currently accept only FormData (a HashMap<String, Value> wrapper). The design should investigate:

  • Form::validate_struct<T: Filterable>(&self, data: &T) — a generic validation path with compile-time checked rules.
  • Form::bind_struct<T: Into<FormData>>(&mut self, data: T) — or a trait-based bridge for hydrating form elements from a concrete struct (the struct provides Into<FormData>).
  • The derive macro can optionally generate impl From<MyStruct> for FormData by iterating fields and calling Value::from(self.field) for each.
  • The FormData / HashMap<String, Value> path remains for JSON payloads, WASM web_sys::FormData, and config-driven forms.

6. Backward compatibility

The HashMap<String, Value> path is preserved for:

  • Config-driven/JSON-deserialized forms
  • WASM boundaries (web_sys::FormData)
  • Dynamic form generation
    The struct-based Filterable path supplements it. A future impl Filterable for HashMap<String, Value> could unify both, but is out of scope for initial design.

Scope

Investigation and design only. Implementation is a follow-up.
Deliverables:

  1. Design document in md/plans/ with trait signatures, derive macro spec, and code sketches.
  2. Decision on open questions listed below.

Open Questions

  1. Nested structs — Should #[derive(Filterable)] support nested struct fields (e.g., address: Address where Address is also Filterable)? This maps naturally to FormData's dot-notation path resolution but adds design complexity. (Recommendation: support this (if too complex, in a separate github ticket))
  2. FieldFilter generic extension — Should FieldFilter itself become generic over T: Filterable, or does Filterable fully replace the need for FieldFilter on concrete types? (Recommendation: Filterable replaces FieldFilter for typed use cases; FieldFilter stays as the dynamic path.)
  3. WASM boundary bridgeFilterable structs likely need Into<FormData> / TryFrom<FormData>. Should the derive macro generate these automatically? (Recommendation: yes, as opt-in via #[derive(Filterable)] attribute flag.)
  4. Proc-macro crate namingwalrs_inputfilter_derive vs. walrs_derive (if it will serve multiple crates later). - Use walrs_inputfilter_derive.

References

Metadata

Metadata

Labels

enhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions