docs: explain what distinguishes validate (+ comment audit) #11

Merged
aneurin merged 4 commits from docs-positioning into main 2026-09-07 16:35:55 +00:00
13 changed files with 340 additions and 36 deletions
+80 -2
View File
@@ -1,7 +1,85 @@
# Go Validate # Go Validate
A suite of straightforward validation functions. You put something in, you get back `nil` or an error. Small, composable value validators for Go. A validator is just a
`func(T) error` — it returns `nil` or an error. You compose them with
`All` or `Collect` and drop in plain closures wherever you need something
custom. No struct tags, no reflection, no dependencies outside the
standard library.
```go
username := validate.All(
validate.MinLength(3),
validate.MaxLength(16),
validate.Chars("abcdefghijklmnopqrstuvwxyz0123456789_"),
func(s string) error {
if strings.HasPrefix(s, "_") {
return errors.New("must not start with an underscore")
}
return nil
},
)
if err := username(input); err != nil {
// errors.Is(err, validate.ErrDisallowedChars) still works
}
```
## Why this and not one of the established packages
| Package | Style |
|---|---|
| [`go-playground/validator`](https://github.com/go-playground/validator) | struct tags (`validate:"required,email"`) driven by reflection |
| [`go-ozzo/ozzo-validation`](https://github.com/go-ozzo/ozzo-validation) | rules composed in code, through a `Rule` interface over `interface{}` |
| [`asaskevich/govalidator`](https://github.com/asaskevich/govalidator) | a bag of `IsEmail` / `IsURL` string helpers |
This package is the code-composition style (closest to ozzo-validation)
with two constraints held on purpose:
- **Validators are ordinary generic functions.** `In`, `Equal` and the
rest are type-checked by the compiler — pass the wrong type and it does
not build. The tag/reflection libraries can't do this; they predate
generics.
- **Errors are plain sentinels.** `errors.Is(err, ErrInvalidEmail)`
composes with the standard `errors` package. There is no bespoke
`ValidationErrors` type to learn. `Err` matches any error from the
package.
Everything is value-level: there is no struct walker. You wire fields
together yourself (a few lines) and decide how to present the result.
## Fail fast or collect everything
```go
// stops at the first failure
validate.All(rules...)
// runs every rule, joins the failures with errors.Join;
// errors.Is still matches each one
validate.Collect(rules...)
```
## Building blocks
| Group | Functions |
|---|---|
| Compose | `All`, `Collect` |
| Presence / equality | `Required`, `Equal`, `In`, `NotIn` |
| String length & content | `MinLength`, `MaxLength` (runes), `MinLengthBytes`, `MaxLengthBytes`, `Chars`, `ExceptChars`, `Prefix`, `Suffix`, `Contains`, `Match` |
| Formats | `Email`, `URL`, `UUID` |
| Numbers | `Min`, `Max`, `MinFloat32`, `MaxFloat32`, `MinFloat64`, `MaxFloat64` |
| Slices | `MinSize`, `MaxSize` |
| Errors | `Error`, `NewError`, `Err` |
Each returns (or is) a `func(T) error`, so anything you write with the
same shape composes with them.
## Not a fit if…
You want **struct-tag validation**, **translated / i18n messages**, or a
**large catalogue** of built-in checks (credit cards, ISO codes, CIDRs, …).
Use [`go-playground/validator`](https://github.com/go-playground/validator)
for that. This package deliberately stays small.
## License ## License
See [LICENSE.md](./LICENSE.md) MIT. See [LICENSE.md](./LICENSE.md).
+3 -2
View File
@@ -1,7 +1,8 @@
package validate package validate
// All validates a value using a sequence of validation functions. // All validates a value against a sequence of validation functions,
// If any validation function returns an error, the sequence stops and the error is returned. // stopping and returning the first error. See Collect to run every
// function and report all failures at once.
func All[T any](fs ...func(T) error) func(T) error { func All[T any](fs ...func(T) error) func(T) error {
return func(value T) error { return func(value T) error {
for _, f := range fs { for _, f := range fs {
+1 -1
View File
@@ -20,7 +20,7 @@ func Chars(allow string) func(string) error {
} }
} }
// ExceptChars validates whether a string does not contain disallowed characters. // ExceptChars validates that a string contains none of the given characters.
func ExceptChars(disallow string) func(string) error { func ExceptChars(disallow string) func(string) error {
return func(value string) error { return func(value string) error {
for _, r := range disallow { for _, r := range disallow {
+1 -1
View File
@@ -13,7 +13,7 @@ func ExampleChars() {
} }
func ExampleExceptChars() { func ExampleExceptChars() {
testExceptChars := Chars("0123456789abcdef") testExceptChars := ExceptChars("0123456789abcdef")
fmt.Println(testExceptChars("invalid input")) fmt.Println(testExceptChars("invalid input"))
// Output: contains disallowed characters // Output: contains disallowed characters
} }
+41
View File
@@ -0,0 +1,41 @@
// Package validate provides small, composable value validators.
//
// A validator is just a func(T) error: it returns nil when the value is
// acceptable, or an error describing the problem. Nothing has to implement
// an interface, so a plain closure works anywhere a validator is expected.
// Combine validators with [All] (stop at the first failure) or [Collect]
// (run them all and join every failure).
//
// username := validate.All(
// validate.MinLength(3),
// validate.MaxLength(16),
// validate.Chars("abcdefghijklmnopqrstuvwxyz0123456789_"),
// )
// if err := username(input); err != nil {
// // errors.Is(err, validate.ErrDisallowedChars) still works here
// }
//
// Errors are plain sentinel values (see [Error]); match them with
// errors.Is. [Err] matches any error produced by this package.
//
// # Comparison with other packages
//
// - go-playground/validator drives validation from struct tags and
// reflection.
// - go-ozzo/ozzo-validation composes rules in code, but through a Rule
// interface over interface{}.
// - asaskevich/govalidator is a bag of IsX string helpers.
//
// This package is the code-composition style with two constraints kept
// deliberately: validators are ordinary generic functions, checked by the
// compiler with no reflection ([In], [Equal] and the rest will not accept
// the wrong type), and there are no dependencies outside the standard
// library.
//
// # When to use something else
//
// Reach for go-playground/validator if you want struct-tag validation,
// translated messages, or a large catalogue of built-in checks. This
// package stays small and leaves struct/field wiring and error
// presentation to the caller.
package validate
+10 -6
View File
@@ -2,10 +2,9 @@ package validate
import "fmt" import "fmt"
// Validation error. // Err is the zero Error. It carries no message, so errors.Is(x, Err) is
var ( // true for any error produced by this package.
Err = Error{} var Err = Error{}
)
// Error represents a validation error. // Error represents a validation error.
type Error struct { type Error struct {
@@ -13,8 +12,9 @@ type Error struct {
Data []any Data []any
} }
// Error retrieves the message of a validation Error. // Error returns the error message. If Data is non-empty, Message is used
// If it has Data, the message will be formatted. // as an fmt.Sprintf format string and Data as its arguments (this is how
// the sentinels with %d/%q verbs are filled in, e.g. via With).
func (e Error) Error() string { func (e Error) Error() string {
if len(e.Data) > 0 { if len(e.Data) > 0 {
return fmt.Sprintf(e.Message, e.Data...) return fmt.Sprintf(e.Message, e.Data...)
@@ -34,6 +34,10 @@ func (e Error) Is(target error) bool {
return false return false
} }
// With returns a copy of the Error with value appended to Data, so it
// lands in the message when Message contains a formatting verb:
//
// ErrMustBeLonger.With(4) // "must contain at least 4 characters"
func (e Error) With(value any) Error { func (e Error) With(value any) Error {
if e.Data == nil { if e.Data == nil {
e.Data = []any{} e.Data = []any{}
+66
View File
@@ -0,0 +1,66 @@
package validate_test
import (
"errors"
"fmt"
"strings"
"code.aneur.in/go/validate"
)
// A validator is any func(T) error. Compose the built-ins with your own
// closures using All (stop at the first failure) or Collect (report all).
func Example() {
username := validate.All(
validate.MinLength(3),
validate.MaxLength(16),
validate.Chars("abcdefghijklmnopqrstuvwxyz0123456789_"),
func(s string) error {
if strings.HasPrefix(s, "_") {
return errors.New("must not start with an underscore")
}
return nil
},
)
fmt.Println(username("ok_name"))
fmt.Println(username("_nope"))
fmt.Println(username("Nope"))
// Output:
// <nil>
// must not start with an underscore
// contains disallowed characters
}
// Collect runs every validator and joins the failures; errors.Is still
// matches each one.
func Example_collectAll() {
password := validate.Collect(
validate.MinLength(8),
validate.Chars("abcdefghijklmnopqrstuvwxyz"),
)
err := password("Ab1")
fmt.Println(err)
fmt.Println(errors.Is(err, validate.ErrMustBeLonger))
fmt.Println(errors.Is(err, validate.ErrDisallowedChars))
// Output:
// must contain at least 8 characters
// contains disallowed characters
// true
// true
}
// Errors are plain sentinels, so errors.Is works with no library-specific
// machinery. Err matches any error from this package.
func Example_errorsIs() {
err := validate.Email("not-an-email")
fmt.Println(err)
fmt.Println(errors.Is(err, validate.ErrInvalidEmail))
fmt.Println(errors.Is(err, validate.Err))
// Output:
// invalid email address
// true
// true
}
+4 -4
View File
@@ -4,7 +4,7 @@ var (
ErrValueNotAllowed = NewError("not allowed") ErrValueNotAllowed = NewError("not allowed")
) )
// In validates whether a value is found in a slice of allowed values. // In validates that a value equals one of the allowed values.
func In[T comparable](allow ...T) func(T) error { func In[T comparable](allow ...T) func(T) error {
return func(value T) error { return func(value T) error {
for _, cmp := range allow { for _, cmp := range allow {
@@ -16,10 +16,10 @@ func In[T comparable](allow ...T) func(T) error {
} }
} }
// NotIn validates whether a value is not found in a slice of disallowed values. // NotIn validates that a value equals none of the disallowed values.
func NotIn[T comparable](allow ...T) func(T) error { func NotIn[T comparable](disallow ...T) func(T) error {
return func(value T) error { return func(value T) error {
for _, cmp := range allow { for _, cmp := range disallow {
if cmp == value { if cmp == value {
return ErrValueNotAllowed return ErrValueNotAllowed
} }
+37 -6
View File
@@ -1,26 +1,57 @@
package validate package validate
import "unicode/utf8"
var ( var (
ErrMustBeLonger = NewError("must contain at least %d characters") ErrMustBeLonger = NewError("must contain at least %d characters")
ErrMustBeShorter = NewError("must contain no more than %d characters") ErrMustBeShorter = NewError("must contain no more than %d characters")
ErrMustHaveMoreBytes = NewError("must have at least %d bytes")
ErrMustHaveFewerBytes = NewError("must have no more than %d bytes")
Outdated
Review

The original intention was to measure text length. Does this make it more correct to measure runes rather than bytes?

The original intention was to measure text length. Does this make it more correct to measure runes rather than bytes?
) )
// MaxLength validates the length of a string as being less than or equal to a given maximum. // MaxLength validates that a string is no longer than a given maximum.
// Length is counted in runes, so multi-byte characters count as one.
func MaxLength(l int) func(string) error { func MaxLength(l int) func(string) error {
return func(value string) error { return func(value string) error {
if len(value) > l { if utf8.RuneCountInString(value) > l {
return ErrMustBeShorter.With(l) return ErrMustBeShorter.With(l)
} }
return nil return nil
} }
} }
// MinLength validates the length of a string as being greater than or equal to a given minimum. // MinLength validates that a string is at least a given minimum length.
// Length is counted in runes, so multi-byte characters count as one.
func MinLength(l int) func(string) error { func MinLength(l int) func(string) error {
return func(value string) error { return func(value string) error {
if len(value) < l { if utf8.RuneCountInString(value) < l {
return ErrMustBeLonger.With(l) return ErrMustBeLonger.With(l)
} }
return nil return nil
} }
} }
// MaxLengthBytes validates that a string is no longer than a given maximum
// number of bytes (len). Prefer [MaxLength] for a limit on visible
// characters; use this when the budget is genuinely a byte count, such as
// a fixed-width column or a wire-format field.
func MaxLengthBytes(l int) func(string) error {
return func(value string) error {
if len(value) > l {
return ErrMustHaveFewerBytes.With(l)
}
return nil
}
}
// MinLengthBytes validates that a string is at least a given minimum
// number of bytes (len). See [MaxLengthBytes] on when to prefer this over
// [MinLength].
func MinLengthBytes(l int) func(string) error {
return func(value string) error {
if len(value) < l {
return ErrMustHaveMoreBytes.With(l)
}
return nil
}
}
+82 -2
View File
@@ -20,7 +20,14 @@ func ExampleMinLength() {
func TestMaxLength(t *testing.T) { func TestMaxLength(t *testing.T) {
testCases := map[int]map[string]error{ testCases := map[int]map[string]error{
8: {"abcd": nil, "abcdefgh": nil, "abcd efg": nil, "abcdefghi": ErrMustBeShorter.With(8)}, 8: {
"abcd": nil,
"abcdefgh": nil,
"abcd efg": nil,
"abcdéfgh": nil, // 8 runes, 9 bytes
"abcdefghi": ErrMustBeShorter.With(8),
"abcdéfghi": ErrMustBeShorter.With(8), // 9 runes, 10 bytes
},
} }
for setup, values := range testCases { for setup, values := range testCases {
@@ -41,7 +48,14 @@ func TestMaxLength(t *testing.T) {
func TestMinLength(t *testing.T) { func TestMinLength(t *testing.T) {
testCases := map[int]map[string]error{ testCases := map[int]map[string]error{
8: {"abcd": ErrMustBeLonger.With(8), "abcdefgh": nil, "abcd efg": nil, "abcdefghi": nil}, 8: {
"abcd": ErrMustBeLonger.With(8),
"abcdéfg": ErrMustBeLonger.With(8), // 7 runes, 8 bytes
"abcdefgh": nil,
"abcdéfgh": nil, // 8 runes, 9 bytes
"abcd efg": nil,
"abcdefghi": nil,
},
} }
for setup, values := range testCases { for setup, values := range testCases {
@@ -59,3 +73,69 @@ func TestMinLength(t *testing.T) {
} }
} }
} }
func ExampleMaxLengthBytes() {
testMaxLengthBytes := MaxLengthBytes(8)
fmt.Println(testMaxLengthBytes("cafés round the world"))
// Output: must have no more than 8 bytes
}
func ExampleMinLengthBytes() {
testMinLengthBytes := MinLengthBytes(8)
fmt.Println(testMinLengthBytes("2short"))
// Output: must have at least 8 bytes
}
func TestMaxLengthBytes(t *testing.T) {
testCases := map[int]map[string]error{
8: {
"abcd": nil,
"abcdefgh": nil,
"abcdéfg": nil, // 7 runes, 8 bytes
"abcdéfgh": ErrMustHaveFewerBytes.With(8), // 8 runes, 9 bytes
"abcdefghi": ErrMustHaveFewerBytes.With(8),
},
}
for setup, values := range testCases {
testMaxLengthBytes := MaxLengthBytes(setup)
for input, want := range values {
t.Run(fmt.Sprintf("%d/%s", setup, input), func(t *testing.T) {
got := testMaxLengthBytes(input)
if !errors.Is(got, want) {
t.Error("got", got)
t.Error("want", want)
}
})
}
}
}
func TestMinLengthBytes(t *testing.T) {
testCases := map[int]map[string]error{
8: {
"abcd": ErrMustHaveMoreBytes.With(8),
"abcdefg": ErrMustHaveMoreBytes.With(8),
"abcdéf": ErrMustHaveMoreBytes.With(8), // 6 runes, 7 bytes
"abcdéfg": nil, // 7 runes, 8 bytes
"abcdefgh": nil,
},
}
for setup, values := range testCases {
testMinLengthBytes := MinLengthBytes(setup)
for input, want := range values {
t.Run(fmt.Sprintf("%d/%s", setup, input), func(t *testing.T) {
got := testMinLengthBytes(input)
if !errors.Is(got, want) {
t.Error("got", got)
t.Error("want", want)
}
})
}
}
}
+9 -9
View File
@@ -8,7 +8,7 @@ var (
) )
// Max validates whether an integer is less than or equal to a given maximum. // Max validates whether an integer is less than or equal to a given maximum.
// If exclusive is true, an equal value will also produce an error. // If exclusive is true, an equal value also produces an error.
func Max(n int, exclusive bool) func(int) error { func Max(n int, exclusive bool) func(int) error {
return func(value int) error { return func(value int) error {
if exclusive { if exclusive {
@@ -24,7 +24,7 @@ func Max(n int, exclusive bool) func(int) error {
} }
// MaxFloat32 validates whether a float32 is less than or equal to a given maximum. // MaxFloat32 validates whether a float32 is less than or equal to a given maximum.
// If exclusive is true, an equal value will also produce an error. // If exclusive is true, an equal value also produces an error.
func MaxFloat32(n float32, exclusive bool) func(float32) error { func MaxFloat32(n float32, exclusive bool) func(float32) error {
return func(value float32) error { return func(value float32) error {
if exclusive { if exclusive {
@@ -40,7 +40,7 @@ func MaxFloat32(n float32, exclusive bool) func(float32) error {
} }
// MaxFloat64 validates whether a float64 is less than or equal to a given maximum. // MaxFloat64 validates whether a float64 is less than or equal to a given maximum.
// If exclusive is true, an equal value will also produce an error. // If exclusive is true, an equal value also produces an error.
func MaxFloat64(n float64, exclusive bool) func(float64) error { func MaxFloat64(n float64, exclusive bool) func(float64) error {
return func(value float64) error { return func(value float64) error {
if exclusive { if exclusive {
@@ -55,8 +55,8 @@ func MaxFloat64(n float64, exclusive bool) func(float64) error {
} }
} }
// Min validates whether an integer is less than or equal to a given maximum. // Min validates whether an integer is greater than or equal to a given minimum.
// If exclusive is true, an equal value will also produce an error. // If exclusive is true, an equal value also produces an error.
func Min(n int, exclusive bool) func(int) error { func Min(n int, exclusive bool) func(int) error {
return func(value int) error { return func(value int) error {
if exclusive { if exclusive {
@@ -71,8 +71,8 @@ func Min(n int, exclusive bool) func(int) error {
} }
} }
// MinFloat32 validates whether a float32 is less than or equal to a given maximum. // MinFloat32 validates whether a float32 is greater than or equal to a given minimum.
// If exclusive is true, an equal value will also produce an error. // If exclusive is true, an equal value also produces an error.
func MinFloat32(n float32, exclusive bool) func(float32) error { func MinFloat32(n float32, exclusive bool) func(float32) error {
return func(value float32) error { return func(value float32) error {
if exclusive { if exclusive {
@@ -87,8 +87,8 @@ func MinFloat32(n float32, exclusive bool) func(float32) error {
} }
} }
// MinFloat64 validates whether a float64 is less than or equal to a given maximum. // MinFloat64 validates whether a float64 is greater than or equal to a given minimum.
// If exclusive is true, an equal value will also produce an error. // If exclusive is true, an equal value also produces an error.
func MinFloat64(n float64, exclusive bool) func(float64) error { func MinFloat64(n float64, exclusive bool) func(float64) error {
return func(value float64) error { return func(value float64) error {
if exclusive { if exclusive {
+3 -1
View File
@@ -6,7 +6,9 @@ var (
ErrInvalidURL Error = NewError("invalid URL") ErrInvalidURL Error = NewError("invalid URL")
) )
// URL validates a URL. // URL validates that a string is an absolute URL (with a scheme) or an
// absolute path, per net/url.ParseRequestURI. "example.com" with no
// scheme is rejected.
func URL(value string) error { func URL(value string) error {
if _, err := url.ParseRequestURI(value); err != nil { if _, err := url.ParseRequestURI(value); err != nil {
return ErrInvalidURL return ErrInvalidURL
+3 -2
View File
@@ -10,8 +10,9 @@ var (
var uuidRegexp = regexp.MustCompile("^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$") var uuidRegexp = regexp.MustCompile("^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$")
// UUID validates a UUID string. // UUID validates a UUID string in the canonical 8-4-4-4-12 hyphenated
// The UUID must be formatted with separators. // form. Only lowercase hexadecimal is accepted; the version and variant
// bits are not checked.
func UUID(value string) error { func UUID(value string) error {
if !uuidRegexp.MatchString(value) { if !uuidRegexp.MatchString(value) {
return ErrInvalidUUID return ErrInvalidUUID