Contents
GoUI's validation lives in two packages that work together:
github.com/zatrano/goui/validation— pure, statelessRulefunctions and theValidaterunner.github.com/zatrano/goui/forms—FieldValidation(embedded in every Tier 1/Tier 2 field) andValidateAll, which wire rules into the render pipeline (error messages,aria-invalid, error CSS class) and preserve submitted values on failure.
import (
"github.com/zatrano/goui/forms"
"github.com/zatrano/goui/validation"
)
1. validation.Rule
type Rule func(value string) (ok bool, messageKey string)
A rule takes the field's current string value and returns whether it
passed, plus an i18n message key to use when it did not. All rule
constructors below return a Rule closure.
Required()
Fails when the value is empty or only whitespace.
validation.Required() // → "validation.required" on failure
MinLength(n int)
Fails when the rune length (not byte length — safe for multi-byte UTF-8
like Turkish ğ, ü, ş, ö, ç, ı, İ) is below n.
validation.MinLength(3) // → "validation.min_length"
MaxLength(n int)
Fails when the rune length is above n.
validation.MaxLength(120) // → "validation.max_length"
Pattern(regex string)
Fails when the value does not match the given regular expression. If
regex itself fails to compile, the returned rule always fails (fails
closed, not open) with "validation.pattern".
validation.Pattern(`^[A-Z]{2}\d{4}$`) // → "validation.pattern"
Email()
Fails when the value does not match a simple [email protected] shape
(^[^@\s]+@[^@\s]+\.[^@\s]+$) — intentionally permissive, not a full RFC
5322 validator.
validation.Email() // → "validation.email"
NumericRange(min, max float64)
Parses the value as a float64 and fails if it doesn't parse, or parses
outside [min, max] inclusive.
validation.NumericRange(0, 100) // → "validation.numeric_range"
Custom(fn func(value string) bool, messageKey string)
Wraps an arbitrary predicate with your own message key — the escape hatch
for anything the built-ins don't cover (uniqueness checks, cross-field
rules layered on top of Validate, business-specific formats, etc.).
validation.Custom(func(v string) bool {
return strings.HasPrefix(v, "TR")
}, "validation.custom")
2. Running rules: Validate and ValidateAll
validation.Validate
func Validate(value string, rules ...Rule) []string
Runs every rule against value — it does not stop at the first
failure — and returns the message keys for every rule that failed, in
order. nil rules in the slice are skipped safely.
keys := validation.Validate("ab", validation.Required(), validation.MinLength(3), validation.Email())
// keys == ["validation.min_length", "validation.email"]
forms.ValidateAll
func ValidateAll(fields ...Validatable) bool
Where Validatable is:
type Validatable interface {
Validate() bool
}
Every Tier 1 and Tier 2 field type implements Validate() bool itself
(internally delegating to its embedded FieldValidation.Run), so you pass
pointers to the fields you want checked:
ok := forms.ValidateAll(&c.Name, &c.Email, &c.Country, &c.Message, &c.Subscribe)
ValidateAll also does not short-circuit — like validation.Validate,
it calls Validate() on every field passed in (so every field's
Errors slice gets populated, not just the first failing one) and returns
true only if all of them passed. nil entries in the list are skipped.
forms.FieldValidation
type FieldValidation struct {
Rules []validation.Rule
Errors []string
}
Embed this in your own field-like types to get validation for free:
type MyField struct {
core.BaseComponent
forms.CommonAttrs
forms.FieldValidation
Value string
}
func (f *MyField) Validate() bool {
return f.FieldValidation.run(f.Value, f.T) // package-private helper on FieldValidation
}
(Tier 1/Tier 2 fields inside the forms/forms packages call the
private run/exported Run variant directly; from outside those packages
use the exported Run:)
func (f *MyField) Validate() bool {
return f.FieldValidation.Run(f.Value, f.T)
}
Run/run does three things:
- Calls
validation.Validate(value, f.Rules...)to get failing message keys. - Translates each key via the
translatefunction you pass (typicallyf.T, so error text respects the component'sLocale) intof.Errors []string. Iftranslateisnil, errors fall back to the raw"[[key]]"form. - Returns
trueonly if there were zero failing keys.
Two more exported helpers on FieldValidation that Tier 1/Tier 2 Render()
implementations call directly:
func (f *FieldValidation) ApplyErrorState(attrs Attrs, baseClass string) Attrs
func (f *FieldValidation) ErrorsHTML() string
ApplyErrorState— ifErrorsis empty, ensuresbaseClassis applied asclass(when the caller hasn't set a custom one) and returnsattrsunchanged otherwise. IfErrorsis non-empty, setsaria-invalid="true"and appends theborder-goui-errorCSS class, merging with any existingclassvalue.ErrorsHTML— renders each entry inErrorsas<p class="goui-field-error text-goui-error text-sm">...</p>(HTML-escaped), concatenated. Every Tier 1/Tier 2 field'sRender()appends this call's result right after the control's own markup.
3. State preserved on failure — no old() gymnastics
In a classic PHP/Laravel-style request/response form, a failed validation
means the whole page reloads and you must manually re-populate every field
from old('field') plus render error bags keyed by field name, or the user
loses everything they typed.
GoUI does not have this problem by construction, because the component
is the state — there is no request/response cycle to lose data across.
When a save/submit event fails validation:
HandleEventcallsforms.ValidateAll(...).ValidateAllreturnsfalse. Every field's ownValue(orChecked,Values, etc.) is already whatever the user last typed — it was set incrementally by each field's ownHandleEvent(g-change/g-input) as the user interacted with it, well before the submit event ever fired.- Each field's
Errors []stringis now populated (from step 1'sValidate()calls). - You call
MarkDirty()and returnnil(or your own error) — no value needs to be copied back into anything. - The next
Render()shows the exact same values the user typed, now with the field's own inline error(s) rendered right after it, andaria-invalid/error-border styling applied automatically byApplyErrorState.
There is no separate "flash" store, no old() helper, and no risk of a
stale/expired session losing form input — the value lives in the same Go
struct for the entire lifetime of the WebSocket session (and survives
reconnects within the grace period, see
04-sessions-and-websocket.md).
4. Full form example
package main
import (
"context"
"html"
"github.com/zatrano/goui/core"
"github.com/zatrano/goui/forms"
"github.com/zatrano/goui/i18n"
"github.com/zatrano/goui/validation"
)
type ContactForm struct {
core.BaseComponent
Name forms.TextInput
Email forms.TextInput
Message forms.Textarea
Submitted bool
Summary string
}
func NewContactForm(tr *i18n.Translator) *ContactForm {
c := &ContactForm{
Name: forms.TextInput{
CommonAttrs: forms.CommonAttrs{Name: "name", ID: "name", Required: true},
Placeholder: "Your name",
FieldValidation: forms.FieldValidation{Rules: []validation.Rule{validation.Required()}},
},
Email: forms.TextInput{
CommonAttrs: forms.CommonAttrs{Name: "email", ID: "email", Required: true},
Type: "email",
Placeholder: "[email protected]",
FieldValidation: forms.FieldValidation{
Rules: []validation.Rule{validation.Required(), validation.Email()},
},
},
Message: forms.Textarea{
CommonAttrs: forms.CommonAttrs{Name: "message", ID: "message"},
Rows: 4,
FieldValidation: forms.FieldValidation{
Rules: []validation.Rule{validation.Required(), validation.MaxLength(500)},
},
},
}
// Sub-fields don't get SetTranslator automatically from ws.Session —
// only the top-level BaseComponent does, so wire each field explicitly.
c.SetTranslator(tr)
c.Name.SetTranslator(tr)
c.Email.SetTranslator(tr)
c.Message.SetTranslator(tr)
return c
}
func (c *ContactForm) Mount(_ context.Context) error { return nil }
func (c *ContactForm) Unmount(_ context.Context) error { return nil }
func (c *ContactForm) HandleEvent(ctx context.Context, event string, payload map[string]any) error {
switch event {
case "name":
return c.Name.HandleEvent(ctx, event, payload)
case "email":
return c.Email.HandleEvent(ctx, event, payload)
case "message":
return c.Message.HandleEvent(ctx, event, payload)
case "save":
if !forms.ValidateAll(&c.Name, &c.Email, &c.Message) {
// Nothing to restore: Name.Value, Email.Value, Message.Value are
// already what the user typed. Each field's Errors is populated.
c.Submitted = false
c.MarkDirty()
return nil
}
c.Submitted = true
c.Summary = c.Name.Value + " <" + c.Email.Value + "> " + c.Message.Value
c.ToastT("success", "contact.submit_success")
c.MarkDirty()
}
return nil
}
func (c *ContactForm) Render() (string, error) {
nameL, _ := (&forms.Label{For: "name", Text: "Name"}).Render()
nameI, _ := c.Name.Render()
emailL, _ := (&forms.Label{For: "email", Text: "Email"}).Render()
emailI, _ := c.Email.Render()
msgL, _ := (&forms.Label{For: "message", Text: "Message"}).Render()
msgI, _ := c.Message.Render()
btn, _ := (&forms.Button{Type: "button", Text: "Send", EventName: "save"}).Render()
out := ""
if c.Submitted {
out = `<div class="result">` + html.EscapeString(c.Summary) + `</div>`
}
inner := forms.JoinHTML(
`<div class="field">`, nameL, nameI, `</div>`,
`<div class="field">`, emailL, emailI, `</div>`,
`<div class="field">`, msgL, msgI, `</div>`,
`<div class="actions">`, btn, `</div>`,
out,
)
form := &forms.Form{Method: "post", OnSubmit: "save", InnerHTML: inner}
return form.Render()
}
Notice what is absent: there is no code anywhere that copies a submitted
value back into Name.Value after a failed save — it was never removed
in the first place. The Errors slice on each field is the only thing
validation adds to the existing state.
For the fully wired, runnable version of this exact pattern (registry,
translator, hub, HTML page, broadcast endpoint), see
examples/contact-form/main.go and run it with:
go run ./examples/contact-form