Skip to content

Design: type-safe Filterable trait for concrete struct validation#91

Draft
Copilot wants to merge 2 commits intomainfrom
copilot/investigate-fieldfilter-validation
Draft

Design: type-safe Filterable trait for concrete struct validation#91
Copilot wants to merge 2 commits intomainfrom
copilot/investigate-fieldfilter-validation

Conversation

Copy link
Contributor

Copilot AI commented Feb 23, 2026

FieldFilter and Form are hardwired to HashMap<String, Value> — field names are runtime strings, values are dynamically typed, and rule/type mismatches are only caught at runtime. This PR adds a design document proposing a trait-based path that restores compile-time guarantees for known structs.

Design document: md/plans/filterable_trait_design.md

  • Filterable trait in walrs_inputfiltervalidate(&self), filter(self), process(self) with provided default
  • #[derive(Filterable)] proc-macro — new walrs_inputfilter_derive crate behind a derive feature gate
  • Attribute annotations#[validate(required, min_length = 3, email, ...)] and #[filter(trim, lowercase, ...)] mapped to existing Rule<T> / Filter<T> enums
  • Cross-field validation — MVP via manual override; stretch goal via #[cross_validate(fn_name)]
  • Nested struct support#[validate(nested)] recursively validates Filterable fields, prefixes violation keys with dot-notation
  • Ownership-based filteringfilter(self) -> Self avoids Default/Clone requirements
  • Form bridge — opt-in Into<FormData> / TryFrom<FormData> generation via #[filterable(into_form_data)]
  • Backward compatible — existing HashMap<String, Value> / FormData path unchanged

Open question decisions

Question Decision
Nested structs Supported via #[validate(nested)]
FieldFilter generic extension Filterable replaces it for typed use cases; FieldFilter stays as dynamic path
WASM bridge Opt-in Into<FormData> / TryFrom<FormData>
Crate naming walrs_inputfilter_derive

Example target API

#[derive(Filterable)]
struct UserAddress {
    #[validate(required, min_length = 3)]
    #[filter(trim)]
    street: String,

    #[validate(required, pattern = r"^\d{5}$")]
    zip: String,
}

let addr = UserAddress { street: "  Main St  ".into(), zip: "90210".into() };
let addr = addr.process()?; // filter then validate

Generated code constructs Rule::<String>::Required.and(Rule::MinLength(3)) — a Rule::<i64>::Min on a String field would be a compile error, not a runtime TypeMismatch.

Original prompt

This section details on the original issue you should resolve

<issue_title>Investigate type-safe FieldFilter and Form for concrete struct validation</issue_title>
<issue_description>## 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-bl...

💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

…cro spec, and code sketches

Co-authored-by: elycruz <603428+elycruz@users.noreply.github.com>
Copilot AI changed the title [WIP] Investigate type-safe FieldFilter and Form for struct validation Design: type-safe Filterable trait for concrete struct validation Feb 23, 2026
Copilot AI requested a review from elycruz February 23, 2026 16:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Investigate type-safe FieldFilter and Form for concrete struct validation

2 participants