Shak
A type-safe Go validation library built on generics and higher-order functions. Uses plain Go constructs — no reflection, no struct tags — so validation rules are just functions that compose naturally with the rest of your code.
Install / Use
/learn @yashx/ShakREADME
shak
shak (pronounced like "shuck") is a Hindi word (शक) that means doubt.
Description
shak is a Go validation library built on generics and higher-order functions — no reflection, no struct tags, no magic. Rules are plain functions, and the type system enforces correctness at compile time.
It has the following features:
- Type-safe by design — powered by Go generics, not reflection.
- Use normal programming constructs rather than struct tags to specify validation rules.
- Validate data of different types — primitives, strings, slices, maps, structs, pointers, and more.
- Validate custom types by implementing the
Validatableinterface. - Compose rules with
And,Or,If, andElsefor expressive conditional logic. - Collect all errors at once or stop at the first failure.
- Structured errors with field paths for precise error reporting.
- Rich set of built-in rules, and easy to add custom ones.
Installation
go get github.com/yashx/shak
Requirements
Go 1.21 or above.
A Note on LLM Usage
The design of this library — its architecture, core abstractions, and the initial set of validation rules — was written by me. LLMs were used to speed up the parts of development I don't find interesting and didn't want to spend my weekends on:
- Tests — unit and integration test coverage
- Documentation — doc comments and this README
- Code review — catching bugs and inconsistencies
- Built-in rules — filling out common use cases beyond the initial set
Getting Started
shak is built around two concepts: rules and validations. A rule is a function that checks a single value. A validation groups rules and fields together and can be executed to collect errors.
- Use
shak.Validate()/shak.ValidateAll()to validate a single value against rules directly. - Use
shak.RunValidation()/shak.RunFullValidation()to execute a structured validation built withvalidation.Value(),validation.Nested(), andvalidation.NewValidations(). - Implement
Validatableon your structs to make them self-describing.
Validating a Simple Value
Use shak.Validate() to validate a single value against one or more rules:
package main
import (
"fmt"
"github.com/yashx/shak"
"github.com/yashx/shak/rule"
)
func main() {
age := 150
err := shak.Validate(age,
rule.Min(0), // must be >= 0
rule.Max(120), // must be <= 120
)
fmt.Println(err)
// Output:
// value 150 is greater than maximum 120
}
Rules run in order. The first failure is returned and the rest are skipped. Use shak.ValidateAll() to collect every error instead:
score := 150
errs := shak.ValidateAll(score,
rule.Min(200), // must be >= 200
rule.Max(100), // must be <= 100
)
for _, err := range errs {
fmt.Println(err)
}
// Output:
// value 150 is less than minimum 200
// value 150 is greater than maximum 100
Validating a Struct
To validate a struct, implement the Validatable interface by defining a Validation() method that describes the rules for each field. Then pass it to shak.RunValidation() or shak.RunFullValidation():
type Address struct {
Street string
City string
}
func (a Address) Validation() validation.Validation {
return validation.NewValidations(
validation.Value("street", a.Street, rule.NotBlank[string](), rule.MinLengthString[string](5)),
validation.Value("city", a.City, rule.NotBlank[string]()),
)
}
a := Address{Street: "123", City: ""}
err := shak.RunValidation(a)
fmt.Println(err)
// Output:
// street: length is 3 bytes, expected at least 5
errs := shak.RunFullValidation(a)
for _, err := range errs {
fmt.Println(err)
}
// Output:
// street: length is 3 bytes, expected at least 5
// city: value must not be blank
Fields are validated in the order they are declared. RunValidation stops at the first failure; RunFullValidation collects errors across all fields.
Nested Structs
When a struct field is itself a Validatable, use validation.Nested() to include its own validation rules. Errors from the inner struct are automatically prefixed with the field name:
type Address struct {
Street string
City string
}
func (a Address) Validation() validation.Validation {
return validation.NewValidations(
validation.Value("street", a.Street, rule.NotBlank[string]()),
validation.Value("city", a.City, rule.NotBlank[string]()),
)
}
type Order struct {
ID int
Address Address
}
func (o Order) Validation() validation.Validation {
return validation.NewValidations(
validation.Value("ID", o.ID, rule.Positive[int]()),
validation.Nested("address", o.Address),
)
}
o := Order{ID: 1, Address: Address{Street: "", City: "Austin"}}
err := shak.RunValidation(o)
fmt.Println(err)
// address.street: value must not be blank
You can also pass outer rules to Nested. They run before the field's own Validation() and receive the whole struct as the value:
validCities := []string{"Austin", "Denver", "Portland"}
func mustBeServicedCity(a Address) *types.ValidationError {
for _, c := range validCities {
if a.City == c {
return nil
}
}
return types.NewValidationError("delivery not available in %q", a.City)
}
func (o Order) Validation() validation.Validation {
return validation.NewValidations(
validation.Value("ID", o.ID, rule.Positive[int]()),
validation.Nested("address", o.Address, mustBeServicedCity),
)
}
o := Order{ID: 1, Address: Address{Street: "1 Oak Ave", City: "Miami"}}
err := shak.RunValidation(o)
fmt.Println(err)
// address: delivery not available in "Miami"
Value vs Nested for a Validatable Field
When a field's type implements Validatable, you have two choices:
validation.Nested— runs any rules you pass and calls the field's ownValidation(). Use this when you want the field to be fully validated as it defines itself.validation.Value— runs only the rules you explicitly pass. The field's ownValidation()is never called. Use this when you want to apply a different or reduced set of rules in a specific context.
// Nested: Address.Validation() runs — street and city are both checked
validation.Nested("address", o.Address)
// Value: only your rule runs — Address.Validation() is ignored
validation.Value("address", o.Address, func(a Address) *types.ValidationError {
if a.City == "" {
return types.NewValidationError("city is required for delivery")
}
return nil
})
Checking Validity Without Errors
When you only need a boolean result and don't care about the error details, use shak.Satisfies() or shak.SatisfiesValidation():
// for a single value
if shak.Satisfies(age, rule.Min(0), rule.Max(120)) {
fmt.Println("age is valid")
}
// for a struct validation
if shak.SatisfiesValidation(address) {
fmt.Println("address is valid")
}
Validating Maps
shak provides rules for validating map keys, values, and specific entries. Map rules take a pointer to the map as the first argument to help the compiler infer types:
scores := map[string]int{"alice": 95, "bob": -1}
// validate every value
err := shak.Validate(scores, rule.ForEachValue(&scores, rule.Min(0)))
// [bob]: value -1 is less than minimum 0
// validate every key
err = shak.Validate(scores, rule.ForEachKey(&scores, rule.NotBlank[string]()))
// validate a specific key
err = shak.Validate(scores, rule.ForKey(&scores, "alice", rule.Max(100)))
Other available map rules:
rule.LengthMap(&m, n)— map must have exactly n entriesrule.MinLengthMap(&m, n)— map must have at least n entriesrule.MaxLengthMap(&m, n)— map must have at most n entriesrule.EqualMap(m2)— map must equal m2rule.EqualMapFunc(&m, m2, eq)— map must equal m2 using a custom equality function
Validating Slices
Like map rules, slice rules take a pointer to the slice as the first argument for type inference:
prices := []float64{9.99, -1.00, 49.99}
// validate every element
err := shak.Validate(prices, rule.ForEach(&prices, rule.Positive[float64]()))
// [1]: value -1 is not positive
// check length
err = shak.Validate(prices, rule.MinLengthSlice(&prices, 1))
// check for duplicates
tags := []string{"go", "go", "validation"}
err = shak.Validate(tags, rule.Unique(&tags))
// duplicate value go found
Other available slice rules:
rule.LengthSlice(&s, n)— slice must have exactly n elementsrule.MaxLengthSlice(&s, n)— slice must have at most n elementsrule.ContainsElem(&s, v)— slice must contain vrule.NotContainsElem(&s, v)— slice must not contain vrule.EqualSlice(s2)— slice must equal s2rule.EqualSliceFunc(&s, s2, eq)— slice must equal s2 using a custom equality functionrule.IsSorted(&s)— slice must be sorted in ascending orderrule.IsSortedFunc(&s, cmp)— slice must be sorted by a custom comparison functionrule.MatchBytes(re)— byte slice must match a regular expressionrule.NotMatchBytes(re)— byte slice must not match a regular expression
Validating a Slice or Map of Validatable Items
Use rule.IsValid[T]() to treat each element's own Validation() as a rule. This composes naturally with ForEach and ForEachValue:
type Pr
