How it works
Constructor: New
import (
"context"
"errors"
"github.com/ygrebnov/model"
modelerrors "github.com/ygrebnov/model/errors"
modelvalidation "github.com/ygrebnov/model/validation"
)
ctx := context.Background()
m, err := model.New(&user,
model.WithDefaults[User](),
model.WithValidation[User](ctx), // run validation during New() with cancellation support
)
if err != nil {
var ve *modelvalidation.Error
switch {
case errors.Is(err, modelerrors.ErrNilObject):
// handle nil object
case errors.Is(err, modelerrors.ErrNotStructPtr):
// handle pointer to non-struct
case errors.As(err, &ve):
// handle field validation failures
default:
// defaults parsing or other errors
}
}
To validate later explicitly, call m.Validate(ctx) with a context appropriate for the request.
When you need the same behavior for many instances of the same type, use model.NewBinding[T]() instead of creating new Model[T] values repeatedly.
Why no MustNew?
MustNew (a variant that panics on configuration errors) is intentionally omitted:
- Panics hinder graceful startup error reporting in services / CLIs.
- All failure modes (
nilobject, non-struct pointer, duplicate rule overload, validation failures when requested) are ordinary and recoverable. - Returning
errorkeeps initialization explicit and test-friendly (you can assert exact sentinel errors or unwrap*validation.Error). - If you truly want a panic wrapper, you can write a 2‑line helper in your own code:
func MustNew[T any](o *T, opts ...model.Option[T]) *model.Model[T] {
m, err := model.New(o, opts...); if err != nil { panic(err) }; return m
}
Functional options
All options run in the order provided. If an option returns an error (e.g., attempting to register a duplicate overload for the same type & name), New stops and returns that error.
WithDefaults[T]() — apply defaults during construction
m, err := model.New(&u, model.WithDefaults[User]())
- Runs once per
Model(guarded bysync.Once). - Writes only zero values.
- This
sync.Oncebehavior is specific toModel[T];Binding[T].ApplyDefaults(...)can be called repeatedly on different objects.
WithValidation[T](ctx context.Context) — run validation during construction
ctx := context.Background()
m, err := model.New(&u,
model.WithValidation[User](ctx),
)
- Gathers all field errors; returns a
*validation.Erroron failure. - Built-ins are always considered first for matching types.
- Cancellation/deadlines follow the provided context.
- To override a built-in rule, register a custom rule before
WithValidation:
minCustom, _ := validation.NewRule[string]("min", func(s string, params ...string) error {
if len(params) == 0 { return fmt.Errorf("min requires 1 param") }
n, _ := strconv.Atoi(params[0])
if len(strings.TrimSpace(s)) < n { return fmt.Errorf("must be at least %d chars after trimming", n) }
return nil
})
m, err := model.New(&u,
model.WithRules[User](minCustom), // override builtin string min
model.WithValidation[User](ctx),
)
WithRules[T](rules ...Rule) — register one or many rules
Create rules with NewRule and pass them. Duplicate exact overloads (same rule name & identical field type) are rejected.
maxLen, _ := validation.NewRule[string]("maxLen", func(s string, params ...string) error {
if len(params) < 1 { return fmt.Errorf("maxLen requires 1 param") }
n, _ := strconv.Atoi(params[0])
if len(s) > n { return fmt.Errorf("must be <= %d chars", n) }
return nil
})
positive64, _ := validation.NewRule[int64]("positive", func(v int64, _ ...string) error {
if v <= 0 { return fmt.Errorf("must be > 0") }
return nil
})
m, _ := model.New(&u,
model.WithRules[User](maxLen, positive64),
)
Model methods
SetDefaults() error
Apply default:"…" / defaultElem:"…" recursively. Safe to call multiple times (subsequent calls no-op).
Validate(ctx context.Context) error
Walk fields and apply rules from validate:"…" / validateElem:"…" tags. Returns *validation.Error on failure.
- Returns
ctx.Err()immediately if the context is canceled or its deadline is exceeded.
Binding[T]
Binding[T] is the reusable version of the same engine.
b, err := model.NewBinding[User]()
if err != nil {
return err
}
if err := b.ValidateWithDefaults(context.Background(), &user); err != nil {
return err
}
This is a good fit for HTTP handlers, workers, or batch processing where many values of the same type are validated repeatedly.
Struct tags (how it works)
Defaults: default:"…" and defaultElem:"…"
- Literals: string, bool, ints/uints, floats,
time.Duration. dive: recurse into struct or*struct(allocating a new struct for nil pointers).alloc: allocate empty slice/map if nil.defaultElem:"dive": recurse into struct elements (slice/array) or map values.
Pointer-to-scalar fields (e.g., *int, *bool) are auto-allocated for literal defaults when nil. Pointer-to-complex types (struct/map/slice) are not auto-allocated for literals.
Validation: validate:"…" and validateElem:"…"
- Comma-separated top-level rules.
- Parameters in parentheses:
rule(p1,p2). - Empty tokens skipped (
,nonempty,→nonempty). validateElem:"dive"recurses into struct elements; non-struct or nil pointer elements produce a misuse error under rule namedive.
Built-in rules
Built-in rules are always implicitly available (you do not need to register or import anything for them):
- String:
min(...),max(...),oneof(...),email,uuid - Int / Int64 / Float64:
min(...),max(...),nonzero,oneof(...)
Overriding: if you register a custom rule with the same name and exact type before validation runs, your rule is chosen (duplicate exact registrations for the same name & type are rejected). Interface-based rules still participate via assignable matching when no exact rule exists.
Structured diagnostics: errorc and keys
Internally, model uses the companion errorc project to wrap low-level errors with structured context, and the companion keys project to define stable metadata keys.
For example, builtin rules attach details such as rule name, parameter name, parameter value, or field type. Those details are then summarized into human-readable validation.FieldError messages while remaining available for programmatic inspection.
That means you get both:
- readable output such as
- Field "Email": rule "email": constraint violated (at_count=1) - machine-friendly underlying errors that can still be matched with
errors.Isor unwrapped further
If you already use errorc and keys elsewhere in your application, model fits naturally into the same error-handling style.
Custom rules (with parameters)
minLen, _ := validation.NewRule[string]("minLen", func(s string, params ...string) error {
if len(params) < 1 { return fmt.Errorf("minLen requires 1 param") }
n, err := strconv.Atoi(params[0])
if err != nil { return fmt.Errorf("minLen: bad param: %w", err) }
if len(s) < n { return fmt.Errorf("must be at least %d chars", n) }
return nil
})
type Payload struct { Body string ` + "`" + `validate:"minLen(3)"` + "`" + ` }
p := Payload{Body: "xy"}
m, _ := model.New(&p, model.WithRules[Payload](minLen))
if err := m.Validate(context.Background()); err != nil {
fmt.Println(err) // Body: must be at least 3 chars (rule minLen)
}
Interface rules:
type stringer interface{ String() string }
stringerRule, _ := validation.NewRule[stringer]("stringerBad", func(s stringer, _ ...string) error {
if s.String() == "" { return fmt.Errorf("empty") }
return fmt.Errorf("bad stringer: %s", s.String()) // demo
})
m, _ := model.New(&obj, model.WithRules[YourType](stringerRule))