Skip to content

Instantly share code, notes, and snippets.

@dhamidi
Created July 13, 2025 14:20
Show Gist options
  • Select an option

  • Save dhamidi/8fef4402da06622ed534c382b18e58f9 to your computer and use it in GitHub Desktop.

Select an option

Save dhamidi/8fef4402da06622ed534c382b18e58f9 to your computer and use it in GitHub Desktop.

Proposal: Better Form System Design

Current Problems

The existing form system has several design issues that create unnecessary complexity:

1. Name Generation Mismatch

  • Field names are auto-generated using strings.ToLower(fieldType.Name)
  • Creates mismatches: CurrencyCodecurrencycode but users expect currency_code
  • No control over field naming, leading to confusing APIs

2. Split Approaches

  • FormFieldValuer approach: Manual field processing with ToFormField()/FromFormField()
  • Reflection approach: core.ParseForm() with commandFormValuer/queryFormValuer
  • Two different ways to do the same thing, neither fully satisfactory

3. Indirect Field Binding

  • Fields are matched to struct members by string name comparison
  • Fragile: field name changes break the connection silently
  • Error-prone: typos in field names cause silent failures

4. Inconsistent Empty Value Handling

  • core.ParseForm() skips empty values entirely (if inputValue != "")
  • Manual approach can handle empty values but requires extra logic
  • No clear distinction between "missing" and "explicitly empty"

5. Complex Error Handling

  • Errors collected in a separate FormErrors object
  • Manual error collection required in many cases
  • Field-to-error association by string name matching

Proposed Solution: Direct Field Binding

Core Concept

Replace string-based field matching with direct references between form fields and their target values.

New Interfaces

// Optional interface for form targets with custom form handling
type FormTarget interface {
    SetFormValue(value string) error
    FormValue() string
    IsEmpty() bool
}

// Enhanced form field with direct target reference
type FormField struct {
    Name        string
    Description string
    Kind        string
    Placeholder string
    Example     string
    Target      any         // Direct reference - can be any type!
    Required    bool
    Validator   func(string) error  // Optional additional validation
}

// AnyFormTarget wraps arbitrary values to implement FormTarget
type AnyFormTarget struct {
    target any
}

func (a *AnyFormTarget) SetFormValue(value string) error {
    if unmarshaler, ok := a.target.(encoding.TextUnmarshaler); ok {
        return unmarshaler.UnmarshalText([]byte(value))
    }
    // Fall back to reflection for basic types
    return setFieldFromReflection(a.target, value)
}

func (a *AnyFormTarget) FormValue() string {
    if marshaler, ok := a.target.(encoding.TextMarshaler); ok {
        if text, err := marshaler.MarshalText(); err == nil {
            return string(text)
        }
    }
    // Fall back to reflection for basic types
    return getFieldFromReflection(a.target)
}

func (a *AnyFormTarget) IsEmpty() bool {
    // Use reflection to compare with zero value
    return reflect.ValueOf(a.target).Elem().IsZero()
}

// Form builder for explicit construction type FormBuilder struct { name string fields []*FormField errors *FormErrors }


### Builder Pattern API

```go
// Explicit, chainable form construction
form := core.NewFormBuilder("payment-form").
    AddField("currency_code", "Three-letter currency code").
        Target(&payment.CurrencyCode).
        Required(true).
        Example("USD").
    AddField("iban", "International Bank Account Number").
        Target(&payment.IBAN).
        Required(false).
        Placeholder("DE89370400440532013000").
    AddField("transfer_step", "Current step in transfer process").
        Target(&payment.TransferStep).
        Required(false).
        Options(payments.AllTransferSteps()).
    Build()

Unified Form Processing

// Helper function to wrap any target as FormTarget
func asFormTarget(target any) FormTarget {
    if formTarget, ok := target.(FormTarget); ok {
        return formTarget
    }
    return &AnyFormTarget{target: target}
}

// Single, simple processing function (renamed to maintain consistency)
func ParseForm(input FormInput, form *Form) error {
    for _, field := range form.Fields() {
        inputValue, hasField := getFieldValue(input, field.Name)
        
        if hasField {
            // Field was provided (even if empty)
            target := asFormTarget(field.Target)
            if err := target.SetFormValue(inputValue); err != nil {
                form.Errors().Field(field.Name, err)
            }
        } else if field.Required {
            // Field missing but required
            form.Errors().Field(field.Name, errors.New("required field missing"))
        }
        // Field missing and optional: no action needed
    }
    
    if !form.Errors().Empty() {
        return form.Errors()
    }
    return nil
}

Benefits

1. Eliminates Name Generation

  • Field names are explicitly specified: AddField("currency_code", ...)
  • No more currencycode vs currency_code confusion
  • Full control over field naming

2. Type-Safe Direct Binding

  • Direct references: Target(&payment.CurrencyCode)
  • No string matching between fields and struct members
  • Compile-time safety: field target must exist

3. Unified Processing

  • One way to process all forms: ParseForm(input, form)
  • Works with any type (via FormTarget interface or automatic wrapping)
  • No split between manual/reflection approaches

4. Flexible Type Support

  • Optional FormTarget interface for custom handling
  • Automatic wrapping via AnyFormTarget for any type
  • Leverages existing encoding.TextMarshaler/TextUnmarshaler interfaces
  • Falls back to reflection for basic types

5. Better Empty Value Handling

  • Explicit control: hasField vs inputValue == ""
  • Clear semantics: missing vs empty vs present
  • Consistent behavior across all form types

6. Enhanced Validation

  • Field-level validation: Validator(func(string) error)
  • Required field validation built-in
  • Better error context and reporting

Implementation Plan

Phase 1: Core Infrastructure DONE

  1. Define optional FormTarget interface
  2. Implement AnyFormTarget wrapper for automatic type handling
  3. Implement FormBuilder with direct binding
  4. Create unified ParseForm() function
  5. Update existing value types to optionally implement FormTarget

Phase 2: Value Type Updates DONE

Optionally update existing value types for better performance/control:

// Optional: Custom FormTarget implementation for better control
func (c *CurrencyCode) SetFormValue(value string) error {
    return c.Set(value)
}

func (c *CurrencyCode) FormValue() string {
    return c.String()
}

func (c *CurrencyCode) IsEmpty() bool {
    return c.value == ""
}

// Note: If not implemented, AnyFormTarget will automatically use:
// - UnmarshalText()/MarshalText() (already implemented)
// - Reflection for IsEmpty() detection

Phase 3: Migration and Compatibility DONE

  1. Provide adapter for existing FormFieldValuer types
  2. Maintain backward compatibility during transition
  3. Update documentation and examples

Phase 4: Advanced Features DONE

  1. Field validation chaining
  2. Conditional field display
  3. Field groups and sections
  4. Custom field types and renderers

Example: Updated form-test

func main() {
    // Parse command line arguments
    args := parseArgs()
    input := &mapFormInput{args: args}
    
    // Create payment struct
    payment := &PaymentForm{
        CurrencyCode: payments.CurrencyCode{},
        IBAN:         payments.IBAN{},
        TransferStep: payments.TransferStep{},
    }
    
    // Build form with explicit field definitions
    form := core.NewFormBuilder("payment-form").
        AddField("currency_code", "Three-letter currency code").
            Target(&payment.CurrencyCode).
            Required(true).
        AddField("iban", "International Bank Account Number").
            Target(&payment.IBAN).
        AddField("transfer_step", "Transfer step").
            Target(&payment.TransferStep).
        Build()
    
    // Process form (handles validation and error collection)
    if err := core.ParseForm(input, form); err != nil {
        fmt.Fprintf(os.Stderr, "Form validation errors: %s\n", err)
        os.Exit(1)
    }
    
    // Output result
    json.NewEncoder(os.Stdout).Encode(payment)
}

Compatibility Strategy

Backward Compatibility

  • Keep existing interfaces during transition
  • Provide adapters: FormFieldValuerFormTarget
  • Gradual migration path for existing code

Migration Path

  1. Phase 1: Implement new system alongside existing
  2. Phase 2: Migrate high-value use cases
  3. Phase 3: Deprecate old interfaces with warnings
  4. Phase 4: Remove old interfaces after migration period

Alternative Considered: Struct Tags

We considered using struct tags for field naming:

type PaymentForm struct {
    CurrencyCode payments.CurrencyCode `form:"currency_code,required" description:"Currency code"`
    IBAN         payments.IBAN         `form:"iban" description:"IBAN"`
    TransferStep payments.TransferStep `form:"transfer_step" description:"Transfer step"`
}

Rejected because:

  • Still relies on reflection for field discovery
  • Tags are error-prone and harder to validate at compile time
  • Less flexible than explicit builder pattern
  • Harder to add complex field configuration

Conclusion

The proposed design eliminates the core problems of the current form system:

  • No name generation: Explicit field naming
  • Direct binding: Type-safe field-to-target connections
  • Unified processing: One ParseForm() function for all forms
  • Flexible type support: Works with any type via optional interface or automatic wrapping
  • Better empty handling: Clear semantics for missing/empty/present
  • Enhanced validation: Built-in required fields, custom validators

This creates a more robust, type-safe, and developer-friendly form system that scales better and reduces the cognitive overhead of working with forms. The optional FormTarget interface provides flexibility while the automatic AnyFormTarget wrapper ensures any type can be used without requiring interface implementation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment