model

package module
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 16, 2025 License: MIT Imports: 8 Imported by: 1

README

GoDoc Build Status Go Report Card

model — defaults & validation for Go structs

model is a tiny helper that binds a Model to your struct. It can:

  • Set defaults from struct tags like default:"…" and defaultElem:"…".
  • Validate fields using named rules from validate:"…" and validateElem:"…".
  • Accumulate all issues into a single ValidationError (no fail-fast).
  • Recurse through nested structs, pointers, slices/arrays, and map values.

It’s designed to be small, explicit, and type-safe (uses generics). You register rules with friendly helpers and model handles traversal, dispatch, and error reporting.


Install

go get github.com/ygrebnov/model

Why use this?

  • Simple API: one constructor and two main methods: SetDefaults() and Validate().
  • Predictable behavior: defaults fill only zero values; validation gathers all issues.
  • Extensible: register your own rules; support interface-based rules (e.g., fmt.Stringer).

Quick start

package main

import (
    "encoding/json"
    "errors"
    "fmt"
    "time"

    "github.com/ygrebnov/model"
)

type Address struct {
    City    string `default:"Paris"  validate:"nonempty"`
    Country string `default:"France" validate:"nonempty"`
}

type User struct {
    Name     string        `default:"Anonymous" validate:"nonempty"`
    Age      int           `default:"18"        validate:"positive,nonzero"`
    Timeout  time.Duration `default:"1s"`
    Home     Address       `default:"dive"`            // recurse into nested struct
    Aliases  []string      `validateElem:"nonempty"`   // validate each element
    Profiles map[string]Address `default:"alloc" defaultElem:"dive"`
}

func main() {
    u := User{Aliases: []string{"", "ok"}} // Tags will flag index 0 as empty

    m, err := model.New(
        &u,
        model.WithRules[User, string](model.BuiltinStringRules()),
        model.WithRules[User, int](model.BuiltinIntRules()),
        model.WithDefaults[User](),   // apply defaults in constructor
        model.WithValidation[User](), // validate in constructor
    )
    if err != nil {
        // When validation fails, err is *model.ValidationError
        var ve *model.ValidationError
        if errors.As(err, &ve) {
            b, _ := json.MarshalIndent(ve, "", "  ")
            fmt.Println(string(b))
        } else {
            fmt.Println("error:", err)
        }
        return
    }

    fmt.Printf("User after defaults: %+v\n", u)

    // You can also call them manually later:
    _ = m.SetDefaults()     // guarded by sync.Once — runs only once per Model
    _ = m.Validate()        // returns *ValidationError on failure
}

Constructor: New

m, err := model.New(&user,
    // You can mix and match options:
    model.WithDefaults[User](),            // apply defaults during New()
    model.WithValidation[User](),          // run Validate() during New()
    model.WithRule[User, string](model.Rule[string]{ // register a single rule
        Name: "nonempty",
        Fn: func(s string, _ ...string) error {
            if s == "" { return fmt.Errorf("must not be empty") }
            return nil
        },
    }),
    model.WithRules[User, int](model.BuiltinIntRules()), // register a batch of rules
)
if err != nil {
    // If WithValidation is used, err can be *model.ValidationError.
}

Notes

  • New returns (*Model[T], error).
  • Misuse (nil object or pointer to a non-struct) panics to enforce invariants.
  • Errors from WithDefaults / WithValidation are returned.

Functional options

WithDefaults[T]() — apply defaults during construction
m, err := model.New(&u, model.WithDefaults[User]())
  • Runs once per Model (guarded by sync.Once).
  • Fills only zero values; non-zero values are left intact.
WithValidation[T]() — run validation during construction
m, err := model.New(&u,
    model.WithRules[User, string](model.BuiltinStringRules()),
    model.WithValidation[User](),
)
  • Make sure the needed rules are registered before validation.
  • Returns a *ValidationError on failure.
WithRule[TObject, TField](Rule[TField]) — register a single rule
m, _ := model.New(&u,
    model.WithRule[User, string](model.Rule[string]{
        Name: "nonempty",
        Fn: func(s string, _ ...string) error {
            if s == "" { return fmt.Errorf("must not be empty") }
            return nil
        },
    }),
)

// Interface rule example (AssignableTo):
type stringer interface{ String() string }
model.WithRule[User, stringer](model.Rule[stringer]{
    Name: "stringerBad",
    Fn: func(s stringer, _ ...string) error {
        return fmt.Errorf("bad stringer: %s", s.String())
    },
})(m)

Dispatch rules: exact type match wins; otherwise uses AssignableTo (e.g., implements an interface). Multiple exact matches cause an ambiguous error.

WithRules[TObject, TField]([]Rule[TField]) — register many at once
m, _ := model.New(&u,
    model.WithRules[User, string](model.BuiltinStringRules()),
    model.WithRules[User, float64](model.BuiltinFloat64Rules()),
)

Model methods

SetDefaults() error

Apply default:"…" / defaultElem:"…" recursively. Guarded by sync.Once.

if err := m.SetDefaults(); err != nil {
    // e.g., a bad literal like default:"oops" on a struct field
    log.Println("defaults error:", err)
}
Validate() error

Walk fields and apply rules from validate:"…" / validateElem:"…".

if err := m.Validate(); err != nil {
    var ve *model.ValidationError
    if errors.As(err, &ve) {
        for field, issues := range ve.ByField() {
            for _, fe := range issues {
                fmt.Printf("%s: %s\n", field, fe.Err)
            }
        }
    }
}

Struct tags (how it works)

Defaults: default:"…" and defaultElem:"…"
  • Literals: strings, bools, ints/uints, floats, time.Duration (e.g., 1h30m).
  • dive: recurse into a struct or *struct field and set its defaults.
  • alloc: allocate an empty slice/map when nil.
  • defaultElem:"dive": recurse into struct elements (slice/array) or map values.
type Config struct {
    Addr    string        `default:"0.0.0.0"`
    Port    int           `default:"8080"`
    Backoff time.Duration `default:"250ms"`

    Limit *int `default:"5"` // pointer-to-scalar allocated & set if nil

    TLS struct {
        Enabled bool   `default:"true"`
        CAFile  string `default:"/etc/ssl/ca.pem"`
    } `default:"dive"`

    Labels  map[string]string `default:"alloc"`
    Servers []Server          `defaultElem:"dive"`
    Peers   map[string]Peer   `default:"alloc" defaultElem:"dive"`
}

Defaults write only zero values. Non-zero values are preserved.

Validation: validate:"…" and validateElem:"…"
  • Multiple rules: validate:"nonempty,min(3),max(10)".
  • Params are strings: rule(p1,p2,…) — parse them inside your rule.
  • validateElem applies to each element (slice/array) or value (map).
  • Special rule name dive: recurse into element structs. If an element is not a struct (or is a nil pointer), a misuse error is recorded under rule "dive".
type Input struct {
    Name   string        `validate:"nonempty"`
    Delay  time.Duration `validate:"nonzeroDur"`
    Tags   []string      `validateElem:"nonempty"`
    Nodes  []Node        `validateElem:"dive"`
    ByName map[string]Node `validateElem:"dive"`
}

Built-in rules

Quick starts for common checks:

model.BuiltinStringRules()  // nonempty
model.BuiltinIntRules()     // positive, nonzero
model.BuiltinInt64Rules()   // positive, nonzero
model.BuiltinFloat64Rules() // positive, nonzero

m, _ := model.New(&u,
    model.WithRules[User, string](model.BuiltinStringRules()),
    model.WithRules[User, int](model.BuiltinIntRules()),
)

Custom rules (with parameters)

// e.g., validate:"minLen(3)"
func minLenRule(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.WithRule[Payload, string](model.Rule[string]{Name: "minLen", Fn: minLenRule}),
)
if err := m.Validate(); err != nil {
    fmt.Println(err) // "Body: must be at least 3 chars (rule minLen)"
}

Interface rules are supported too:

type stringer interface{ String() string }
model.WithRule[YourType, stringer](model.Rule[stringer]{
    Name: "stringerOk",
    Fn: func(s stringer, _ ...string) error {
        if s.String() == "" { return fmt.Errorf("empty") }
        return nil
    },
})(m)

Error types

FieldError

Represents a single failure.

fe := model.FieldError{Path: "User.Name", Rule: "nonempty", Err: fmt.Errorf("must not be empty")}
fmt.Println(fe.Error()) // "User.Name: must not be empty (rule nonempty)"

b, _ := fe.MarshalJSON() // {"path":"User.Name","rule":"nonempty","message":"must not be empty"}
ValidationError

Accumulates many FieldErrors.

var ve *model.ValidationError
if errors.As(err, &ve) {
    fmt.Println(ve.Len(), "issues")
    fmt.Println(ve.Fields())   // ["Name", "Tags[0]", …]
    fmt.Println(ve.ForField("Name"))
    fmt.Println(ve.ByField())  // map[string][]FieldError
    fmt.Println(ve.Unwrap())   // errors.Join of underlying causes

    b, _ := json.MarshalIndent(ve, "", "  ")
    fmt.Println(string(b))
}

Behavior notes

  • SetDefaults() is idempotent per Model (guarded by sync.Once).
  • Creating a new Model for the same object pointer can apply defaults again — safe because only zero values are filled.
  • default:"dive" auto-allocates *struct pointers when nil. For collections, use default:"alloc" to allocate.
  • validateElem:"dive" recurses into struct elements and records a misuse error for non-struct or nil pointer elements/values.

Minimal example

package main

import (
    "encoding/json"
    "errors"
    "fmt"
    "time"

    "github.com/ygrebnov/model"
)

type Cfg struct {
    Name string        `default:"svc" validate:"nonempty"`
    Wait time.Duration `default:"500ms"`
}

func main() {
    cfg := Cfg{}
    m, err := model.New(&cfg,
        model.WithRules[Cfg, string](model.BuiltinStringRules()),
        model.WithDefaults[Cfg](),
        model.WithValidation[Cfg](),
    )
    if err != nil {
        var ve *model.ValidationError
        if errors.As(err, &ve) {
            b, _ := json.MarshalIndent(ve, "", "  ")
            fmt.Println(string(b))
        } else {
            fmt.Println("error:", err)
        }
        return
    }

    _ = m // model bound to cfg
    fmt.Printf("OK: %+v\n", cfg)
}

License

Distributed under the MIT License. See the LICENSE file for details.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type FieldError

type FieldError struct {
	Path   string   // dotted path to the field (e.g., Address.Street)
	Rule   string   // rule name that failed
	Params []string // parameters provided to the rule via validate tag
	Err    error    // underlying error from the rule
}

FieldError represents a single validation failure for a specific field and rule. It implements error and unwraps to the underlying cause so callers can use errors.Is/As.

func (FieldError) Error

func (e FieldError) Error() string

func (FieldError) MarshalJSON

func (e FieldError) MarshalJSON() ([]byte, error)

MarshalJSON exports FieldError as an object with path, rule, and message fields.

func (FieldError) Unwrap

func (e FieldError) Unwrap() error

type Model

type Model[TObject any] struct {
	// contains filtered or unexported fields
}

func New

func New[TObject any](obj *TObject, opts ...Option[TObject]) (*Model[TObject], error)

func (*Model[TObject]) SetDefaults

func (m *Model[TObject]) SetDefaults() error

SetDefaults applies default values based on `default:"..."` tags to the model's object. It is safe to call multiple times; only zero-valued fields are set.

func (*Model[TObject]) Validate

func (m *Model[TObject]) Validate() error

Validate runs the registered validation rules against the model's bound object. It delegates to the internal validate method which performs the actual work.

type Option

type Option[TObject any] func(*Model[TObject])

Option configures a Model at construction time.

func WithDefaults

func WithDefaults[TObject any]() Option[TObject]

WithDefaults enables applying defaults during New(). If not specified, defaults are NOT applied automatically.

func WithRule

func WithRule[TObject any, TField any](rule Rule[TField]) Option[TObject]

WithRule registers a named validation rule into the model's validator registry.

func WithRules

func WithRules[TObject any, TField any](rules []Rule[TField]) Option[TObject]

WithRules registers multiple named rules of the same field type at once.

func WithValidation

func WithValidation[TObject any]() Option[TObject]

WithValidation enables running Validate() during New(). If validation fails, New() panics.

type Rule

type Rule[TField any] struct {
	Name string
	Fn   RuleFn[TField]
}

Rule associates a name with a RuleFn for registration in the model's validator registry. The Name is referenced by the `validate:"..."` tag on struct fields.

func BuiltinFloat64Rules

func BuiltinFloat64Rules() []Rule[float64]

func BuiltinInt64Rules

func BuiltinInt64Rules() []Rule[int64]

func BuiltinIntRules

func BuiltinIntRules() []Rule[int]

func BuiltinStringRules

func BuiltinStringRules() []Rule[string]

type RuleFn

type RuleFn[TField any] func(value TField, params ...string) error

RuleFn is the user-facing signature for a validation rule on fields of type TField. Params come from the `validate` tag, e.g. validate:"min(3)" -> params ["3"]. Return nil if valid, or an error describing the validation failure.

type ValidationError

type ValidationError struct {
	// contains filtered or unexported fields
}

ValidationError accumulates multiple FieldError entries. It implements error and unwraps to errors.Join of underlying causes so errors.Is/As continue to work for callers.

func (*ValidationError) Add

func (ve *ValidationError) Add(fe FieldError)

Add appends a FieldError.

func (*ValidationError) Addf

func (ve *ValidationError) Addf(path, rule string, err error)

Addf is a convenience to add from parts.

func (*ValidationError) ByField

func (ve *ValidationError) ByField() map[string][]FieldError

ByField groups issues by dotted field path.

func (*ValidationError) Empty

func (ve *ValidationError) Empty() bool

Empty reports whether there are no issues.

func (*ValidationError) Error

func (ve *ValidationError) Error() string

Error returns a human-readable, multi-line description of all issues.

func (*ValidationError) Fields

func (ve *ValidationError) Fields() []string

Fields returns the list of field paths that have issues (unique, order preserved by first occurrence).

func (*ValidationError) ForField

func (ve *ValidationError) ForField(path string) []FieldError

ForField returns all issues for a given dotted field path.

func (*ValidationError) Len

func (ve *ValidationError) Len() int

Len returns the number of accumulated issues.

func (*ValidationError) MarshalJSON

func (ve *ValidationError) MarshalJSON() ([]byte, error)

MarshalJSON exports ValidationError as a map of field path -> list of error messages. Example:

{
  "Name": ["must not be empty"],
  "Age":  ["must be > 0", "must not be zero"]
}

func (*ValidationError) Unwrap

func (ve *ValidationError) Unwrap() error

Unwrap joins underlying causes so errors.Is/As keep working on the combined error.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL