Validating data in Go #
Mat Ryer’s approach to
validating data in Go
is simple and elegant.
Define a Validator interface and whatever type implements this interface
is validation-ready:
type Validator interface {
Validate() map[string]string
}
The Validate method returns a map of field names to problem descriptions.
The map is a bit unwieldy,
so I like returning a Problems type instead:
type Problems map[string]string
We can add syntactic sugar to make it more convenient to use:
func (p Problems) Add(field, msg string) {
p[field] = msg
}
We also want a String method to turn problems
into a human-readable string representation:
func (p Problems) String() string {
if len(p) == 0 {
return ""
}
msgs := make([]string, 0, len(p))
for field, msg := range p {
msgs = append(msgs, fmt.Sprintf("%s: %s", field, msg))
}
return strings.Join(msgs, "; ")
}
Implementing the interface #
With all that tooling in place,
consider this TaskCreateRequest type.
Callers use this type to register a task that is meant to be run periodically.
type TaskCreateRequest struct {
Name string
Run func(ctx context.Context) error
Interval time.Duration
Deadline time.Duration
}
A valid variable of this type must satisfy a number of constraints:
- The name must not be empty.
- The run function must not be
nil. - Both the interval and deadline must be greater than 0.
- The deadline must be less than the interval, otherwise two instances of the task can run simultaneously.
We implement these constraints by having the TaskCreateRequest
type implement the Validator interface:
func (r *TaskCreateRequest) Validate() validation.Problems {
problems := make(validation.Problems)
if r.Name == "" {
problems.Add("Name", "name is required")
}
if r.Run == nil {
problems.Add("Func", "func is required")
}
if r.Interval <= 0 {
problems.Add("Interval", "interval must be greater than 0")
}
if r.Deadline <= 0 {
problems.Add("Deadline", "deadline must be greater than 0")
}
if r.Deadline > r.Interval {
problems.Add("Deadline", "deadline must be less than interval")
}
return problems
}
The Problems type is defined in the validation package,
which makes for a descriptive fully qualified import
called validation.Problems.
Validating types #
Calling Validate directly is a bit cumbersome because
validation.Problems does not implement the error interface
and callers shouldn’t have to bother with the custom type.
Instead,
we define a Check function – also in the validation package –
that calls Validate and returns an error
if the given type has one or more problems.
We use some reflection magic to get the type’s name.
func Check(v Validator) error {
if v == nil {
return errors.New("value is nil, cannot validate")
}
problems := v.Validate()
if len(problems) == 0 {
return nil
}
return fmt.Errorf("validation failed for %s: %s",
typeName(v),
problems.String(),
)
}
func typeName(v any) string {
t := reflect.TypeOf(v)
if t == nil {
return "value"
}
// Keep on dereferencing the pointer until we get to the concrete type.
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Name() != "" {
return t.Name()
}
return t.String()
}
The task registration code now only has to call Check
and handle the error in a Go-idiomatic way:
func RegisterTask(req *TaskCreateRequest) error {
if err := validation.Check(req); err != nil {
return err
}
...
}
If we provide an invalid task creation request, like this one:
if err := RegisterTask(&TaskCreateRequest{
// Name and Deadline are missing!
Run: func(ctx context.Context) error {
return nil
},
Interval: time.Second,
}); err != nil {
log.Fatal(err)
}
…we end up with the following error:
2026/04/16 16:56:31 validation failed for TaskCreateRequest: Deadline: must be greater than 0; Name: is required
Succinct and actionable.
Where should we validate? #
Go programs that implement an HTTP API often follow
the controller/service/repository pattern.
Controllers implement an access layer that allows clients to interact with the
system.
The controller can be a traditional HTTP API,
or implement gRPC,
or simply use stdin and stdout.
Services implement business logic.
Repositories implement persistence,
typically by interacting with a database,
or by implementing an in-memory data store,
which can be useful for testing.
In a controller/service/repository pattern, the service is responsible for validating whatever enters its boundaries. Every service method that allows for the creation of a new resource (or the modification of an existing one) validates incoming data.
I find it convenient to have validation code right next to the corresponding type definition. Whenever we find that validation logic is incomplete or overzealous, we instantly know where to look and adjust the logic.
Usually,
I build some extra tooling on top of the Validator interface,
like a Merge method for the Problems type,
which helps with nested validation.