Skip to main content

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 by sync.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 name dive.

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))