The existing form system has several design issues that create unnecessary complexity:
- Field names are auto-generated using
strings.ToLower(fieldType.Name) - Creates mismatches:
CurrencyCode→currencycodebut users expectcurrency_code - No control over field naming, leading to confusing APIs
- FormFieldValuer approach: Manual field processing with
ToFormField()/FromFormField() - Reflection approach:
core.ParseForm()withcommandFormValuer/queryFormValuer - Two different ways to do the same thing, neither fully satisfactory
- 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
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"
- Errors collected in a separate
FormErrorsobject - Manual error collection required in many cases
- Field-to-error association by string name matching
Replace string-based field matching with direct references between form fields and their target values.
// 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()
// 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
}- Field names are explicitly specified:
AddField("currency_code", ...) - No more
currencycodevscurrency_codeconfusion - Full control over field naming
- Direct references:
Target(&payment.CurrencyCode) - No string matching between fields and struct members
- Compile-time safety: field target must exist
- One way to process all forms:
ParseForm(input, form) - Works with any type (via
FormTargetinterface or automatic wrapping) - No split between manual/reflection approaches
- Optional
FormTargetinterface for custom handling - Automatic wrapping via
AnyFormTargetfor any type - Leverages existing
encoding.TextMarshaler/TextUnmarshalerinterfaces - Falls back to reflection for basic types
- Explicit control:
hasFieldvsinputValue == "" - Clear semantics: missing vs empty vs present
- Consistent behavior across all form types
- Field-level validation:
Validator(func(string) error) - Required field validation built-in
- Better error context and reporting
- Define optional
FormTargetinterface - Implement
AnyFormTargetwrapper for automatic type handling - Implement
FormBuilderwith direct binding - Create unified
ParseForm()function - Update existing value types to optionally implement
FormTarget
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- Provide adapter for existing
FormFieldValuertypes - Maintain backward compatibility during transition
- Update documentation and examples
- Field validation chaining
- Conditional field display
- Field groups and sections
- Custom field types and renderers
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)
}- Keep existing interfaces during transition
- Provide adapters:
FormFieldValuer→FormTarget - Gradual migration path for existing code
- Phase 1: Implement new system alongside existing
- Phase 2: Migrate high-value use cases
- Phase 3: Deprecate old interfaces with warnings
- Phase 4: Remove old interfaces after migration period
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
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.