How it works
Constructor: New
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 *model.ValidationError
switch {
case errors.Is(err, model.ErrNilObject):
// handle nil object
case errors.Is(err, model.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.
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 (
nil
object, non-struct pointer, duplicate rule overload, validation failures when requested) are ordinary and recoverable. - Returning
error
keeps initialization explicit and test-friendly (you can assert exact sentinel errors or unwrap*ValidationError
). - 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.
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
*ValidationError
on 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
:
nonemptyCustom, _ := model.NewRule[string]("nonempty", func(s string, _ ...string) error {
if strings.TrimSpace(s) == "" { return fmt.Errorf("must not be blank or whitespace") }
return nil
})
m, err := model.New(&u,
model.WithRules[User](nonemptyCustom), // override
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, _ := model.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, _ := model.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 *ValidationError
on failure.
- Returns
ctx.Err()
immediately if the context is canceled or its deadline is exceeded.
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:
nonempty
,oneof(...)
- Int / Int64 / Float64:
positive
,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.
Custom rules (with parameters)
minLen, _ := model.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, _ := model.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))