| name | go-form-builder |
| description | Build and validate forms with the layrz-forms Go library. Use for struct tags, Validate(), IsValid(), Clean<Field> hooks, FieldError, error codes, pointer vs. value fields, GraphQL inputs, and subform recursion. GO-SPECIFIC only โ see form-builder router for Python. |
Go form builder
layrz-forms is a struct validation library. A form is a pointer to a struct with exported fields tagged with layrz:. Call Validate(&form) to get an Errors map (field โ error slices), keyed in camelCase. This guide covers the Go API and the exact semantics that make pointer and value fields work differently.
Import and entry points
import "github.com/goldenm-software/layrz-forms/go/v3"
Key exports:
Validate(form any) Errors โ validates and returns all errors, keyed by camelCase field name. Form MUST be a non-nil pointer to a struct; passing a value or nil yields a _config error.
IsValid(form any) bool โ shorthand for Validate(form).IsEmpty().
FieldError โ single error with Code (string), Expected (any), Received (any), Extra (map[string]any).
Errors โ map[string][]*FieldError, the result type.
The form argument must always be a NON-NIL POINTER TO A STRUCT. This requirement exists because Clean methods have pointer receivers โ without a pointer, receivers are invisible to reflection, so the engine cannot find or invoke them. Bad input yields a _config error instead of a panic.
errs := Validate(form)
errs := Validate(&form)
Struct tag grammar
Tags are declared on struct fields as layrz:"<rules>". The first token is the field kind; the rest are optional rules separated by commas.
Grammar:
layrz:"<kind>[,rule][,rule]..."
Field kinds (mutually exclusive):
id โ int or string ID; required to be > 0
email โ email string
uuid โ UUID string (hyphenated, unhyphenated, braced, or urn: prefix formats accepted)
char โ arbitrary string
number โ int or float numeric value
bool โ boolean
json โ slice or map (JSON-like container)
subform โ pointer to a nested struct (validated recursively)
subform_list โ slice of struct or slice of pointer-to-struct
Rules (applied to the kind):
required โ field is absent if nil (pointer fields) or missing (value fields never absent). No value.
empty โ empty string or container is OK. Bare keyword. Default: false (empty rejected).
min_length=<int> โ minimum string length in runes.
max_length=<int> โ maximum string length in runes.
min_value=<float> โ minimum numeric value.
max_value=<float> โ maximum numeric value.
choices=<a|b|c> โ string must be one of the pipe-separated values.
regex=<pattern> โ string must match pattern. MUST BE LAST in the tag because its value may contain commas. Pattern is compiled; invalid patterns produce a _config error.
datatype=<type> โ disambiguate numeric or JSON type. Values: int (integers only), float (floats only), list (arrays), dict (maps). Mostly inferred from Go type; use to override.
- โ skip this field entirely.
Common mistakes:
- Using
: instead of =: required:true is wrong; use bare required.
- Forgetting
=: min_length5 is wrong; use min_length=5.
regex= not last: layrz:"char,regex=[0-9]+,required" fails because regex consumes the rest of the string. Move it to the end.
Examples:
type User struct {
ID *int `layrz:"id,required"`
Email *string `layrz:"email,required"`
Name *string `layrz:"char,required,min_length=2,max_length=50"`
Age *int `layrz:"number,min_value=0,max_value=150"`
Status *string `layrz:"char,choices=active|inactive|pending"`
Description *string `layrz:"char,regex=^[a-z0-9]+$"`
Active *bool `layrz:"bool,required"`
Tags *[]any `layrz:"json,datatype=list"`
Metadata *map[string]any `layrz:"json,datatype=dict"`
Address *Address `layrz:"subform"`
Items []*Item `layrz:"subform_list"`
Ignored string `layrz:"-"`
Untagged string
}
Pointer vs. value fields: the key concept
This is the single most important semantic difference from Python. In Go, a POINTER field models absence, and a VALUE field is always present.
Pointer fields (absence is nil)
A pointer field is nil when absent.
type Form struct {
Email *string `layrz:"email,required"`
Age *int `layrz:"number,required"`
}
- Nil โ absent โ
required fires if rule is set, otherwise no error
- Non-nil โ present โ validate the pointed value
Value fields (always present, even at zero)
A value field has a Go zero value that counts as PRESENT. A zero string "" is present. A zero int 0 is present.
type Form struct {
Name string `layrz:"char,required"`
Count int `layrz:"number,required,min_value=1"`
}
required never fires on a value field because the zero value is always present
empty does fire on a zero-length string or empty slice/map
- Other rules (min/max, regex, choices) apply to the zero value
Consequences
Example 1: a required string field
type F1 struct {
Name *string `layrz:"char,required"`
}
f := &F1{Name: nil}
f = &F1{Name: Ptr("")}
f = &F1{Name: Ptr("Bob")}
type F2 struct {
Name string `layrz:"char,required"`
}
f := &F2{Name: ""}
f = &F2{Name: "Bob"}
Example 2: a number with min bound
type F1 struct {
Count *int64 `layrz:"number,min_value=1"`
}
f := &F1{Count: nil}
f = &F1{Count: Ptr(0)}
type F2 struct {
Count int64 `layrz:"number,min_value=1"`
}
f := &F2{Count: 0}
f = &F2{Count: 1}
Example 3: a bool field
type F1 struct {
Active *bool `layrz:"bool,required"`
}
f := &F1{Active: nil}
f = &F1{Active: Ptr(false)}
type F2 struct {
Active bool `layrz:"bool,required"`
}
f := &F2{Active: false}
f = &F2{Active: true}
Accepted types per kind
| Kind | Pointer Forms | Value Forms |
|---|
id | *int, *int64, *string | int, int64, string |
email | *string | string |
uuid | *string | string |
char | *string | string |
number | *int, *int8...64, *float32, *float64 | int, int8...64, float32, float64 |
bool | *bool | bool |
json | *[]any, *map[string]any, *[]T, *map[string]T | []any, map[string]any, []T, map[string]T |
subform | *struct | (not supported; must be pointer) |
subform_list | []*struct, []struct | N/A |
GraphQL interop
layrz-forms integrates cleanly with graph-gophers/graphql-go input types. GraphQL matches fields to Go by name (case-insensitive, underscores stripped in the matching), NOT by json: or layrz: tags. This means validation tags never collide with GraphQL.
Key rule: GraphQL rejects a pointer for a non-null (!) input field. A String! requires a value field; a nullable String maps to a pointer field.
Example GraphQL schema:
input CreateUserInput {
id: Int!
email: String!
bio: String
age: Int
}
Corresponding Go struct:
type CreateUserInput struct {
ID int `layrz:"id,required"`
Email string `layrz:"email,required"`
Bio *string `layrz:"char"`
Age *int `layrz:"number,min_value=0"`
}
GraphQL sets fields by name matching and populates the struct. Then call Validate(&input). Custom scalars (like a UUID struct or Status enum) should be left untagged and validated in a Convention A hook:
type UUID struct {
Value string
Valid bool
}
type Form struct {
UserID UUID `layrz:"-"`
}
func (f *Form) CleanUserID(value UUID) *FieldError {
if !value.Valid {
return &FieldError{Code: "invalid"}
}
return nil
}
Clean hooks: per-field and cross-field
After built-in field rules, the engine discovers and calls clean methods. Two conventions:
Convention A: per-field
Signature: func (f *MyForm) Clean<FieldName>(value <FieldType>) *FieldError
- Method name is
Clean + the Go struct field name exactly (e.g., CleanEmail for field Email).
- Parameter type must match the field's type exactly (pointer or value).
- Returns
*FieldError or nil.
- Error files under the field's camelCase key.
- Used for single-field validation logic.
Example:
type LoginForm struct {
Email *string `layrz:"email,required"`
Password *string `layrz:"char,required,min_length=8"`
}
func (f *LoginForm) CleanEmail(value *string) *FieldError {
if value == nil {
return nil
}
if strings.HasSuffix(*value, "@blocked.com") {
return &FieldError{Code: "blockedDomain"}
}
return nil
}
func (f *LoginForm) CleanPassword(value *string) *FieldError {
if value == nil {
return nil
}
if strings.Contains(*value, " ") {
return &FieldError{Code: "noSpacesAllowed"}
}
return nil
}
Parameter type must be exact:
type Form struct {
Age *int `layrz:"number,required"`
}
func (f *Form) CleanAge(value *int) *FieldError { ... }
func (f *Form) CleanAge(value int) *FieldError { ... }
Convention B: cross-field
Signature: func (f *MyForm) Clean<Suffix>() Errors
- Method name is
Clean + any suffix not matching a struct field name (e.g., CleanPasswords, CleanConsistency).
- No parameters (receiver only).
- Returns
Errors (the full map), or nil.
- Can report under arbitrary keys.
- Used for cross-field logic.
Example:
type PasswordResetForm struct {
NewPassword *string `layrz:"char,required,min_length=8"`
ConfirmPassword *string `layrz:"char,required,min_length=8"`
}
func (f *PasswordResetForm) CleanPasswords() layrz.Errors {
if f.NewPassword == nil || f.ConfirmPassword == nil {
return nil
}
if *f.NewPassword != *f.ConfirmPassword {
return layrz.Errors{
"passwordMismatch": {{Code: "mismatch"}},
}
}
return nil
}
Execution order and visibility
- Phase 1: Built-in field rules (tag validators) run first across the entire form.
- Phase 2: Convention A hooks (per-field) run alphabetically by method name. They see the field value; custom logic can reject it.
- Phase 3: Convention B hooks (cross-field) run alphabetically by method name. They see the full form state and can report under any key.
Custom hooks only execute if the pointer receiver exists and the method signature is correct. A signature mismatch or panic in a hook produces a _config error instead of crashing.
Nested structures: subform and subform_list
Subform (pointer to struct)
Use subform kind on a pointer-to-struct field. Recursively validates the nested struct and prefixes error keys with the subform's camelCase field name.
type Address struct {
Street *string `layrz:"char,required,min_length=5"`
City *string `layrz:"char,required"`
}
type CreateUserForm struct {
Name *string `layrz:"char,required"`
Address *Address `layrz:"subform"`
}
Validating:
form := &CreateUserForm{
Name: Ptr("Alice"),
Address: &Address{
Street: Ptr("123"),
City: nil,
},
}
errs := Validate(&form)
Nil subforms are skipped โ no errors for a nil Address field. This is a deliberate divergence from Python, which would report it as missing. In Go, a nil subform is assumed to be intentionally absent.
Subform list (slice of struct or pointer-to-struct)
Use subform_list kind on a slice field. Each element is validated at index keys, and nil elements are skipped without shifting sibling indices.
type Item struct {
Name *string `layrz:"char,required"`
Price *float64 `layrz:"number,required,min_value=0"`
}
type OrderForm struct {
Items []*Item `layrz:"subform_list"`
}
Validating:
form := &OrderForm{
Items: []*Item{
{Name: nil, Price: Ptr(10.0)},
{Name: Ptr("Widget"), Price: Ptr(-5.0)},
nil,
},
}
errs := Validate(&form)
Elements can be values or pointers:
type OrderForm struct {
Items []Item `layrz:"subform_list"`
Items []*Item `layrz:"subform_list"`
}
Recursion depth guard: The engine tracks recursion depth (maximum 32 levels). Exceeding this limit adds an internalError to the _config key.
Error structure and the _config key
FieldError fields:
Code (string) โ always set. Examples: required, invalid, empty, minLength.
Expected (any) โ when meaningful (e.g., min bound, allowed choices). Omitted from JSON if unset.
Received (any) โ the actual value. Omitted from JSON if unset.
Extra (map[string]any) โ arbitrary context. Omitted from JSON if unset.
Errors type methods:
Add(key, errs...) โ append to the key's error slice.
Merge(other Errors) โ merge another error map, converting keys to camelCase.
IsEmpty() bool โ true if no errors.
Keys() []string โ sorted field names.
json.Marshal(errs) โ produces {key: [{code, expected?, received?, extra?}], ...}.
Reserved key: _config
Configuration and internal errors (bad tag, type mismatch, hook signature error, recovered panic, recursion limit) are filed under _config. This key should never appear in production; its presence indicates a programming bug, not a validation failure:
form := &struct{
X *string `layrz:"invalid_kind"`
}{}
errs := Validate(&form)
if errs["_config"] != nil {
}
Casing: snake_case to camelCase gotcha
Field names are converted to camelCase via ToCamelCase(), which lowercases only the first character. Go acronyms mangle predictably:
ID โ iD
URL โ uRL
HTTPCode โ hTTPCode
This behavior is intentional and matches Python's algorithm exactly, so cross-language forms produce identical keys. If a specific key is needed, name the Go field accordingly:
type Form struct {
IDValue *string
ID *string
}
Validation error codes and rules
Each kind produces specific codes under specific conditions:
| Kind | Rule | Code | Expected | Received | Notes |
|---|
id | required | required | โ | โ | nil (pointer) |
id | type/value | invalid | โ | โ | non-int/string, โค 0, wrong type |
email | required | required | โ | โ | nil |
email | empty | empty | โ | โ | "" and Empty==false |
email | regex | invalid | โ | โ | doesn't match pattern |
uuid | required | required | โ | โ | nil |
uuid | format | invalid | โ | โ | not a valid UUID format |
char | required | required | โ | โ | nil |
char | empty | empty | โ | โ | "" and Empty==false |
char | min_length | minLength | min (int) | length (int) | rune count < min |
char | max_length | maxLength | max (int) | length (int) | rune count > max |
char | choices | invalidChoice | []string | value (string) | not in list |
char | regex | invalidFormat | pattern (string) | value (string) | doesn't match |
number | required | required | โ | โ | nil |
number | type/datatype | invalid | โ | โ | wrong numeric type, wrong datatype |
number | min_value | minValue | bound (float or int64) | value (float or int64) | < min |
number | max_value | maxValue | bound (float or int64) | value (float or int64) | > max |
bool | required | required | โ | โ | nil |
json | required | required | โ | โ | nil |
json | type | invalid | โ | โ | wrong container type (list vs dict) or empty when Empty==false |
Common mistakes
- Colon instead of equals:
layrz:"char:required" โ fails with parse error. Use char,required.
- Passing a value instead of pointer:
Validate(form) โ _config error. Use Validate(&form).
- Value receiver on Clean method:
func (f Form) CleanName(...) ... โ method not found. Use pointer receiver func (f *Form) CleanName(...) ....
- Convention A parameter type mismatch: Field is
*int, but hook takes int โ _config error. Match the type exactly.
- Untagged field:
Name string (no tag) is silently skipped. Add a tag to validate it.
- Expecting
required on value field: Count int with required โ no error on zero. Use empty:false to reject zero-length containers.
- Regex not last:
layrz:"char,regex=[0-9]+,required" โ regex eats the remaining tokens. Put regex at the end.
- Nil subform with subform kind:
Address *Address with subform tag โ a nil subform is skipped, not errored. Either make it required (pointer, custom Convention A hook) or accept the absence.
Verifying behavior
The Go implementation is pinned by cross-language test vectors in vectors/fields/*.json (one per kind, ~96 total cases). When unsure whether an input produces a specific error, check the vectors:
cd /home/mochi/Projects/layrz-forms
grep -E '"name"|"value_absent"|"expected_errors"' vectors/fields/NumberField.json | head -20
go test ./... -v
make test
After writing a form, run go vet ./... and go test ./... to confirm no compilation or logic errors.
Example: comprehensive form with all features
package main
import (
"github.com/goldenm-software/layrz-forms/go/v3"
)
type Address struct {
Street *string `layrz:"char,required,min_length=5"`
City *string `layrz:"char,required"`
Zip *string `layrz:"char,required,regex=^[0-9]{5}$"`
}
type Item struct {
Name *string `layrz:"char,required"`
Quantity *int `layrz:"number,required,min_value=1"`
Price *float64 `layrz:"number,required,min_value=0"`
}
type CreateOrderForm struct {
OrderID *int `layrz:"id,required"`
Email *string `layrz:"email,required"`
Description *string `layrz:"char,empty"`
Address *Address `layrz:"subform"`
Items []*Item `layrz:"subform_list"`
}
func (f *CreateOrderForm) CleanEmail(value *string) *layrz.FieldError {
if value != nil && len(*value) > 0 {
if strings.HasSuffix(*value, "@spam.com") {
return &layrz.FieldError{Code: "spamDomain"}
}
}
return nil
}
func (f *CreateOrderForm) CleanItems() layrz.Errors {
if len(f.Items) == 0 {
return layrz.Errors{
"items": {{Code: "atLeastOne"}},
}
}
return nil
}
func main() {
form := &CreateOrderForm{
OrderID: layrz.Ptr(123),
Email: layrz.Ptr("user@example.com"),
Address: &Address{
Street: layrz.Ptr("Main"),
City: layrz.Ptr("NYC"),
Zip: layrz.Ptr("10001"),
},
Items: []*Item{
{Name: layrz.Ptr("Widget"), Quantity: layrz.Ptr(2), Price: layrz.Ptr(10.0)},
},
}
errs := layrz.Validate(form)
if !layrz.IsValid(form) {
for _, key := range errs.Keys() {
for _, err := range errs[key] {
println(key, ":", err.Code)
}
}
}
}
Output on invalid form:
address.street : minLength