Skip to main content technophobic.dev

Tackling the Problem With the String Values and the Struct

Some of you may be familiar with the problem of binding string values, such as those from a POST request or URL query parameter in the form of url.Values and there are some (good) libraries that have been developed specifically for this purpose, for example the quite popular go-playground/form package.

My problem with such libraries is that they are usually based on reflection – which can be slow and is generally unpopular for good reasons – and that you need to have a little secret esoteric knowledge in your head to avoid mishandling niche cases or, if necessary, write your own handlers to convert data to the correct data type. It is also not convenient to validate the data, so you first convert the incoming data into a DTO and then check it using a separate validator.

Fortunately, a few years ago, the Go developers have heard our wishes and prayers and implemented generics, so we (or I) can rethink how this can be achieved in a type-safe manner for my existing and coming projects.

The basics

Apart from converting the values to the correct data type, this is more or less the only thing needed:

go code snippet start

type Binder[T any] interface {
	BindValue(target *T, path []string, values []string) error
}

type BinderFunc[T any] func(target *T, path []string, values []string) error

func (f BinderFunc[T]) BindValue(target *T,	path []string, values []string) error {
	return f(target, path, values)
}

go code snippet end

If this seems familiar, there’s a good reason for that; it’s inspired by the net/http handlers and is therefore as simple as it can be powerful. Yes, you have to do a little groundwork here to become productive, as the values don’t parse themselves, but it also offers the possibility to define your own, type safe rules. Let’s take, for example, a binder that binds a string representation to a boolean:

go code snippet start

func NewBoolBinder[T ~bool]() BinderFunc[T] {
	return func(target *T, _ []string, values []string) error {
		v, err := strconv.ParseBool(values[0])
		if err != nil {
			return err
		}
		*target = T(v)
		return nil
	}
}

go code snippet end

In practice, this would be used as follows:

go code snippet start

binder := NewBoolBinder[bool]()

var b bool
if err := binder.BindValue(&b, nil, []string{"1"}); err != nil {
    // do something
}

go code snippet end

Since this binder is stateless (as all binders should be, if possible), it can be initialized once and simply reused for further operations.

Increasing the complexity

There is also nothing to prevent you from calling other binders (or the same binder again) within a binder, which allows to prepare complex binders in a simple, transparent way. Let’s take the following struct as an example:

go code snippet start

type Node struct {
	Value string
	Next  []*Node
}

// The corresponding values. If this is not clear, the keys 
// will be split to act as path.
var values map[string][]string{
	"value":               []string{"foo"}
	"next.0.value":        []string{"bar"}
	"next.0.next.0.value": []string{"baz"}
}

go code snippet end

Here we need two binders, one for string and one for Node. What is special here is that we have a slice, and the slice contains pointer types. This has to be handled somehow, so two additional supporting binders are needed. However, since everything is implemented with generics, this is not a problem, because these binders are reusable for every data type.

go code snippet start

// nodeBinder uses itself, so we have to declare it first
var nodeBinder BinderFunc[Node]

// Simply applies the first value to the target
var stringBinder = BinderFunc[string](func(target *string, _ []string, values []string) error {
	*target = values[0]
	return nil
})

func init() {
	nodeBinder = func(target *Node, path []string, values []string) error {
		switch path[0] {
		case "value":
			return stringBinder.BindValue(&target.Value, path, values)
		case "next":
			nxt := NewSliceBinder(NewPtrBinder(nodeBinder))
			return nxt.BindValue(&target.Next, path[1:], values)
		}
		return nil
	}
}


func NewPtrBinder[T any](binder Binder[T]) BinderFunc[*T] {
    return func(target **T, path []string, values []string) error {
        return binder.BindValue(*target, path, values)
    }
}

func NewSliceBinder[T any](binder Binder[T]) BinderFunc[[]T] {
    return func(target *[]T, path []string, values []string) error {
        // This is simplified and assumes the index idx exists. 
		// Remember: target is a pointer. 
		idx, err := strconv.Atoi(path[0])
        return binder.BindValue(&(*target)[idx], path[1:], values)
    }
}

go code snippet end

Validating incoming data

Simply binding the values is often not enough; they usually also need to be checked for their validity. There are several ways to do this, but the simplest and most flexible is to write a binder that validates the target:

go code snippet start

func WithValidation[T any](binder Binder[T], val func(*T) error) BinderFunc[T] {
	return func(target *T, path []string, values []string) error {
		err := binder.BindValue(target, path, values)
		if err != nil {
			return err
		}
		return val(target)
	}
}

go code snippet end

Pros and Cons

I understand that it is easier and faster (to implement) to use reflection for this, especially when a project contains dozens or hundreds of DTOs. However, I want to know as much as possible about what is actually going on in my code base, and having control over how the data is bound or validated can be beneficial as the number of calls increases.

Custom solution Reflection-based
Ease of use Once everything is set up, it’s relatively easy to define new handlers and map complex structures, but it does take it’s time (unless you use CG) Very easy
Speed It depends largely on the implementation of the individual binders and the path split, but it can be very fast Reflection is usually fast enough
Transparency Very expressive Most cases will be obvious, others not so much
Validation Easy to achieve using multiple approaches Requires custom checks or additional validation library
Extensibility As with http.Handlers, you can do some crazy stuff Depends on the library, most offer some level of extensibility

I am currently working on an improved and more sophisticated version, which I am planning to move into a separate package, and I am planning to add some benchmark comparisons as well, which may be covered in a follow-up post.