Simon Rygård

Lightweight Validation Go Package for Struct Field Values

Despite several drafts in the past year I haven’t published anything here since almost exactly a year ago. Being busy on a new team at work learning an entirely new tech stack has taken its toll on headspace… But back now with a quick post thanks to a push from an unexpected direction!

Introduction

Back when working regularly with Go I found myself grasping for a way to validate struct field values. To me, it makes sense to keep the validation definition close to the struct definition. So, why not attempt to use struct tags for this purpose? They are defined right there in the struct definition anyway.

It turns out that this is not a novel idea (duh). There are several libraries that do this, of which go-playground/validator seems to be the most feature complete and popular. However, it is a quite a large library and maybe a dependency you don’t want to add to your project. So, I decided to try to implement a simple version of this myself.

Struct Tags

Go has a feature called struct tags. These are backtick strings that can be added to struct fields. They can then be used to add metadata to the field. The absolutely most common use case is to add json tags to the fields to control the marshalling and unmarshalling of the struct. If not defined, the field name (in lowercase) is used.

type Person struct {
    Name string `json:"my_name"`
    Age int     `json:"my_age"`
}

This will result in the following JSON when marshalled:

{
    "my_name": "",
    "my_age": 0
}

Struct tags can also be used for any “arbitrary” purpose, good or bad. Through reflection and the reflect package, Go provides a way to access these tags at runtime. We will come back to this later.

Validation

The idea is to use struct tags to define validation rules for the fields. The rules can be defined in a comma separated list. For example, a rule to check that a string is not empty could be defined as notempty.

type Person struct {
    Name string `validate:"notempty"`
    Age int     `validate:"min=0,max=120"`
}

p := Person{Name: "", Age: 17}

// Somehow validate the struct...

Implementation

Let’s make the API straightforward. The validate tag is parsed and the rules are applied to the struct fields. The user initializes a Validator and registers the rules they want to use. The Validate method is then called with the struct to validate it.

validator := tagval.NewValidator()

validator.RegisterOperator(reflect.String, "notempty", func(v any, _ string) bool {
    return v.(string) != ""
}

validator.RegisterOperator(reflect.Int, "min", func(v any, arg string) bool {
    min, err := strconv.Atoi(arg)
    if err != nil {
        return false
    }
    return v.(int) >= min
}

validator.RegisterOperator(reflect.Int, "max", func(v any, arg string) bool {
    max, err := strconv.Atoi(arg)
    if err != nil {
        return false
    }
    return v.(int) <= max
}

valid, err := validator.Validate(Person{Name: "", Age: 17})
if err != nil {
    log.Fatal(err)
}
fmt.Println(valid) // false

valid, err = validator.Validate(Person{Name: "Alice", Age: 18})
if err != nil {
    log.Fatal(err)
}
fmt.Println(valid) // true

The code is available in the tagval Github repository. So far it’s a proof of concept and not feature complete. However, the strength, compared to the go-playground/validator library, is that it is left to the user to define the rules and hence not reliant on rules being defined “centrally”.

A Note on Reflection

The implementation relies on reflection to get the struct fields and their tags at runtime in a kind of “generic” way. Reflection has been a part of Go since the start and is a powerful tool. However, it also comes with hit to performance. This package aims to do most of the reflection work at Validator initialization/registration and then store the struct/field mapping to functions for use at Validate time.

Next Steps

So far, I’ve only tested the package with types in the Go standard library, like int and string. The natural next step is to implement/ensure support for custom types and nested structs. Most likely this will change the API.

Additionally, I think it would be nice if the Validate method also somehow returned the fields that failed validation and the reason for the failure. Wether to do this as part of the error message or as a separate return value is not clear to me yet. I know Go added support for joining errors in Go 1.20, so maybe that could be used?