kanna-value turns a struct holding one value and a validate method into a value type — constructors, an accessor,
and JSON and SQL methods — that holds only what its rule accepts, wherever the value comes from. It also turns a string
type with constants into an enum that reads from outside only the values of its constants.
Part of the kanna family of code generators. Requires Go 1.26.
Install
go get -tool github.com/go-kanna/value/cmd/kanna-valueRules are written with the package github.com/go-kanna/value/rule, which the line above already requires. It depends
on nothing outside the standard library.
Use
Hold the value in a struct with one unexported field, and write its rule as a validate method:
package model
import "github.com/go-kanna/value/rule"
//go:generate go tool kanna-value
//kanna:value
type Email struct{ s string }
func (Email) validate(s string) error { return rule.Email(s) }
//kanna:value
type CompletedAt struct{ t time.Time }
func (CompletedAt) validate(t time.Time) error {
if t.Before(opened) {
return rule.FailWith("greater_than_or_equal_to", map[string]any{"count": opened})
}
return nil
}go generate ./... writes value_gen.go next to it:
// Code generated by kanna-value. DO NOT EDIT.
func NewEmail(v string) (Email, error) // checks v with validate
func MustEmail(v string) Email // for a constant: panics if v is rejected
func (x Email) String() string // the string held
func (x Email) IsZero() bool
func (x Email) MarshalJSON() ([]byte, error)
func (x *Email) UnmarshalJSON(data []byte) error // checks what it decodes
func (x Email) Value() (driver.Value, error)
func (x *Email) Scan(src any) error // checks what it scans
func NewCompletedAt(v time.Time) (CompletedAt, error)
func MustCompletedAt(v time.Time) CompletedAt
func (x CompletedAt) Time() time.Time
func (x CompletedAt) Equal(y CompletedAt) bool
// String, IsZero, MarshalJSON, UnmarshalJSON, Value, and Scan, as for EmailThe field is unexported, so outside the package a value comes only from NewEmail, MustEmail, UnmarshalJSON, or
Scan, and each of them runs validate. Code receiving an Email need not check it again.
A value type is a type of its own, named as you named it in errors and documentation, and it takes methods like any other:
func (e Email) Domain() string {
_, domain, _ := strings.Cut(e.s, "@")
return domain
}A struct that cannot become a value type stops generation, rather than the first value that meets it:
$ go generate ./...
./model.go:6:6: error: Email has no validate method to check its values with
add func (Email) validate(string) error, returning nil to accept the value
./model.go:9:17: error: ID.Value must be unexported, so that a value is set only through the generated code
rename it to value
./model.go:14:19: error: Tags holds a []string, which has no name to call the method returning it after
declare a named type for it, and hold that
./model.go:21:1: warning: "//kanna:valeu" is not recognized as a directive
did you mean "//kanna:value"?Code may call what is generated before it exists, as when a type moves to kanna-value or its callers are written
first. kanna-value type-checks the packages it loads once more with what it is about to write in place, and writes
nothing while their code does not compile with it: a mistake the missing constructor hid is reported then, such as a
method that the value type does not have.
Another package whose generator reads the value type, such as a kanna-mapper package, needs its value_gen.go to exist,
so on that first run, generate the value type's package before it.
What is generated
For a struct X holding a value of type T in its field t:
| Declaration | What it does |
|---|---|
NewX(v T) (X, error) |
returns the X holding v if validate accepts it, and its error otherwise |
MustX(v T) X |
NewX for values known to be valid, such as constants; panics if v is rejected |
| accessor | returns the value, named after T: String for a string, Int64 for an int64, Time for a time.Time, UUID for a uuid.UUID
|
String() string |
the value's own String if it has one, or else fmt's %v; for a string, the accessor itself |
IsZero() bool |
the value's own IsZero if it has one, or else == with the zero value, or == nil for a slice or a map |
Equal(y X) bool |
only when T has Equal(T) bool, as time.Time does |
MarshalJSON, UnmarshalJSON
|
encode the value as T encodes; decode a T and keep it only if validate accepts it |
Value, Scan
|
convert the value as database/sql converts a T; scan a T and keep it only if validate accepts it |
An unexported struct gets unexported constructors, newX and mustX.
A few things follow from what T is:
-
Tis a basic or a named type, after which the accessor is named. A slice is held through a type of its own, such astype tags []string. - A slice or a map is copied on the way in and on the way out, with
slices.Cloneormaps.Clone, so that nothing changes it pastvalidate. What it holds is not copied in turn, nor is a slice inside a struct: hold values that are not changed in place. -
IsZerotells the zero value as Go does: a nil slice is zero, an empty one is not. ATthat neither==nor== nilnor a method of its own can tell zero, such as a struct holding a slice, gets noIsZero; encoding/json'somitzerofalls back on reflection for it. - A zero given is the zero never given: when the rule accepts the zero value, as a price of 0,
MustPrice(0)equalsPrice{},IsZeroreports both,omitzeroleaves both out, and kanna-validate'srequiredreports bothblank. Where a value of zero and no value at all mean different things, hold a*Price, nil when none was given. -
Equalexists because==can be wrong for a time: two of the same instant differ when one carries a monotonic clock reading or another location, as a time read back from a database does.
Decoding and scanning leave the value as it was on any error, and so does JSON null. A NULL column is an error: scan a
column that may be NULL into a *X, which database/sql sets to nil.
A method or a constructor you have written under one of the generated names stops generation instead of being
overwritten. Equal and IsZero are yours to write when none is generated.
Enums
Mark a string type whose values are the constants declared beside it with //kanna:enum:
//kanna:enum
type OrderStatus string
const (
OrderStatusPlaced OrderStatus = "placed"
OrderStatusPaid OrderStatus = "paid"
OrderStatusShipped OrderStatus = "shipped"
OrderStatusCanceled OrderStatus = "canceled"
)go generate ./... adds to value_gen.go:
func NewOrderStatus(v string) (OrderStatus, error) // OrderStatus(v), if it is the value of a constant
func OrderStatusValues() []OrderStatus // the constants in the order they are declared, in a new slice
func (x *OrderStatus) UnmarshalText(text []byte) error // what encoding/json decodes a string or an object key with
func (x *OrderStatus) Scan(src any) errorEach of them rejects a value that is none of the constants with rule.Fail("inclusion"), the code kanna-validate's
oneof reports too, and leaves the value as it was. A NULL column is an error, as for a value type.
The type stays a string type with constants, so a switch over them, linters checking that a switch is exhaustive, and
other generators reading the constants work as before. Nothing is generated to encode a value, as encoding/json and
database/sql already send it as the string it is. What the generated code checks is what comes from outside: within the
program, OrderStatus("shiped") compiles as for any string type, so make values with the constants or with
NewOrderStatus.
An enum that cannot be generated stops generation:
$ go generate ./...
./order.go:5:6: error: OrderStatus has no constants to be one of
declare them beside it, such as const OrderStatusA OrderStatus = "a"
./order.go:12:2: error: OrderStatusSettled holds "paid", as OrderStatusPaid does
give each constant a value of its own
./order.go:13:2: error: OrderStatusNone holds "", the zero value of OrderStatus, which stands for none
remove it, and use a *OrderStatus where a value may be absent
./order.go:20:6: error: Kind is not a struct, which //kanna:value marks
mark it //kanna:enum to accept only the constants declared beside itAn unexported enum gets unexported functions, newX and xValues.
Rules
A validate method returns nil to accept a value, and an error to reject it. The package rule provides what rules are
written with:
| Declaration | Rejects with |
|---|---|
rule.Fail(code), rule.FailWith(code, params)
|
an *rule.Error with your code and params |
rule.Email(s) |
email, unless s is a bare address such as a@example.com
|
rule.URL(s) |
url, unless s is an absolute URL with a host, such as https://example.com
|
rule.UUID(s) |
uuid, unless s is a UUID in its standard textual form, of any version |
A rule is a plain function, so rules combine as any Go code does:
func (CompanyEmail) validate(s string) error {
if err := rule.Email(s); err != nil {
return err
}
if !strings.HasSuffix(s, "@example.com") {
return rule.Fail("corp_domain")
}
return nil
}The code names the broken rule in a form stable enough to key a translated message or an API response, and the params
carry what the message needs. kanna-validate reports the codes of Rails' ActiveModel::Validations, such as too_long
with a count; using them for your own rules keeps an API's errors alike. An error formats as rule: too_long (count=3). The rejected value is never part of it: errors end up in logs, and the value may be personal data.
Changing a rule
Scan holds a row to the rule as it is now, as UnmarshalJSON holds a request, so a rule made stricter changes what
the rows already stored can be read as: a row the old rule accepted and the new one rejects fails to scan, and a query
listing it fails with it. Before such a change goes out, find the rows it would reject, mend or move them, and then
deploy the stricter rule. An enum losing a constant is the same change. One gaining a constant is too, the other way
round: a release rolled back after rows took the new value cannot read them.
That such a row fails rather than being read as it is follows from what a value type is: a value from outside the package, a database included, is always one its rule accepts.
With kanna-validate
kanna-validate checks the fields of a request by their tags. A value type takes
care of its own format, so its field needs at most required, which kanna-validate checks with IsZero:
//kanna:validate
type SignUp struct {
Name string `json:"name" validate:"required"`
Email model.Email `json:"email" validate:"required"`
}validate.DecodeJSON records a value that UnmarshalJSON rejects at its field's path, with its code and params, and
goes on decoding, so the response lists it together with whatever the tags find:
{"errors": {"email": [{"error": "email"}], "name": [{"error": "blank"}]}}An enum needs no oneof either: a status that UnmarshalText rejects is recorded as
{"errors": {"status": [{"error": "inclusion"}]}}.
Linting
wrapcheck reports return rule.Email(s) in a validate method as an unwrapped error from another package. The error
is the rule's verdict, with nothing underneath to wrap, so let the package through:
linters:
settings:
wrapcheck:
ignore-package-globs:
- github.com/go-kanna/value/ruleFlags
kanna-value [flags] [packages]| Flag | Meaning |
|---|---|
-tags <list> |
comma-separated build tags |
-check |
verify the output is up to date instead of writing it, including that no output is left over |
-v |
list the files written, removed, or left unchanged |
-version |
print the version |
The packages default to the one in the current directory, so //go:generate go tool kanna-value needs no argument. A
run that succeeds prints nothing but its warnings. One of them is about the run itself: when nothing in its packages is
marked //kanna:value or //kanna:enum, it succeeds, since a //go:generate line left behind does no harm, but says
so, since the line was most likely meant to go as well:
$ go generate ./...
warning: nothing is marked //kanna:value or //kanna:enum in example.com/app/modelA package whose types have all lost their directive has its value_gen.go removed, unless code in the packages the
run loads still calls what it declares; a file of that name without the generated-code header is never touched. The
go generate that removes it ends with open ...: no such file or directory, from go generate itself, which lists a
package's files before it runs the generators; the file is gone as it should be
(golang/go#36068).
Example
example builds the values of a task service from constants and from input, decodes requests holding them
— one valid, one with too many tags, one completed before the service began, one with a priority that is none of its
enum's constants — and shows that a slice it was given cannot be changed from outside. CI regenerates it and fails if the
output would change.
Development
make test # unit tests
make lint # golangci-lint
make example # regenerate the example, then build and run it