Skip to main content

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 (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 *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 by sync.Once).
  • Writes only zero values.
  • This sync.Once behavior is specific to Model[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.Error 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:
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 name dive.

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.Is or 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))