Blog

Building your own form decoder using Go

On Tuesday, Aug 19, 2025
post image

If you’ve built a Go web app, you’ve probably had to deal with form data—whether it comes from HTML forms or API clients sending application/x-www-form-urlencoded form data.

For that reason, we just move to implement decoder packages that work, sure—but they often include features you’ll never use, plus a stack of third-party dependencies you don’t really want to maintain. The truth is, writing your own decoder isn’t that hard—and doing it yourself will give you full control over how form fields map into your structs.

The purpose of this post is to encourage you to build a simple form decoder from scratch. The decoder will map incoming form data directly to your struct fields, supporting nested struct fields, slices, pointers, built-in types, and even custom types.

Before diving into the code, it’s worth seeing the whole process. This is a flowchart that outlines the path our decoder will follow—from looping through struct fields, checking tags, handling pointers, and branching into structs, slices, or built-in types.

UML workflow

This will be our roadmap as we walk through the implementation step by step.

1. The Decode Function

Let’s start creating a new form package within the project. This will contain the Decode function, which takes the parsed request form data—as url.Values—the pointer to the destination struct. Additionally, it will return an error if a value cannot be correctly decoded.


func Decode(form url.Values, dst any) error {
	//...
}

We will rely on the reflect package to help us. This one provides tools to inspect the struct fields. Initially, dst must be converted into a reflect.Value element using the reflect ValueOf() function to get the value stored within it. With the reflect.Value we can identify if is a pointer to a struct; otherwise, the decoder has to return an error. Why? Well, since the request form information comes as key-value data, we can’t infer the kind of value from the keys alone.


func Decode(form url.Values, dst any) error {
	v := reflect.ValueOf(dst)

	if reflect.TypeOf(dst) != reflect.Ptr || v.Elem().Kind() != reflect.Struct {
		return errors.New("destination must be pointer to struct")
	}
}

2. Loop Through Struct Fields

Once we’ve confirmed we’re trying to decode a pointer to a struct, we’ll need to loop through its fields. Skipping those that are unexported, or their form tag value is different from -.


t := v.Type()
for i := range t.NumField() {
	field := t.Field(i)
	fieldVal := elem.Field(i)
	tagName := field.Tag.Get("form")

	if !field.IsExported() || tagName == "-" || !fieldVal.CanSet() {
		continue
	}

	fName := cmp.Or(tagName, field.Name)
	fType := field.Type
	fKind := fType.Kind()
	isPtr := fKind == reflect.Ptr
	// ...
}

3. Handling pointers

It’s time to validate the field is a pointer. When this case happens, we need to initialize it before assigning any value; otherwise 💀 panic: runtime error: invalid memory address or nil pointer dereference. Let’s break down what happens here by trying to decode the following struct.


type MyStruct struct {
	V *T `form:"field"`
}

When the decoder is looping the struct fields and finds V, is needed to check that it’s a pointer. If so, the pointer must be initialized with an empty T value so there’s allocated memory to store the decoded value.

However, if the form data doesn’t contain the field key and the underlying type reflect.Kind is not a struct (reflect.Struct), the field is skipped without initializing it—this is to avoid creating unneeded memory allocations for values that won’t be used.


fName := cmp.Or(tagName, field.Name) // field
fType := field.Type                  // *T
fKind := fType.Kind()                // reflect.Ptr

if fKind == reflect.Ptr {
  fType = fType.Elem() // Getting the underlying type: T
  fKind = fType.Kind() // Getting the kind of T: can be a struct, slice, or built-in type.

  // Skipping field decoding if form data doesn't contain the field key
  if _, ok := form[fName]; !ok && fKind != reflect.Struct {
    continue
  }

  if fieldVal.IsNil() {
    fieldVal.Set(reflect.New(fType)) // Initializing MyStruct.V field
  }
}

4. Decoding the field

Now the fields are validated and also pointers are initialized, it’s time to assign the form value to them. We handle it based on its kind: struct, slice, or “everything else” (built-in types).


switch fKind {
case reflect.Struct:
	// Decode struct
case reflect.Slice:
	// Decode slice
default:
	// Decode built-in type
}

Decoding built-in types

Each field type must be handled differently from the others; for that reason, you need to have a dictionary that returns the decoder function for a determined type. Let’s call it builtInDecoders. Their keys will be reflect.Kind and the decoder function will have the signature func(value string) (any, error) where value is the form value that will be parsed and returns the parsed value as an any and an error if the value cannot be parsed to the expected type.


builtInDecoders = map[reflect.Kind]func(string) (any, error){
	reflect.Float64: func(value string) (any, error) {
		v, err := strconv.ParseFloat(value, 64)
		if err != nil {
			return nil, fmt.Errorf("invalid float64 value: %q", value)
		}
		return v, nil
	},
}

default:
	values := form[fName]

	if len(values) > 0 {
		decoderFn, ok := builtInDecoders[fKind]
		if ok {
			v, err := dec(value)
			if err != nil {
				return fmt.Errorf("cannot convert to %q: %w", fieldValue.Kind(), err)
			}

			fieldVal.Set(reflect.ValueOf(v))
		}
	}

What about custom types like time.Time or UUIDs?. Let’s continue with the decoding struct section.

Decoding structs

When the field is a struct, the decoder can recursively call itself to handle it. This recursive call must look for form values that match the prefix defined by the struct field name or its form tag. For instance, let’s check the following example:


type User struct {
	FirstName string `form:"first_name"`
	LastName string `form:"last_name"`
	Address struct {
		City string `form:"city"`
		State string `form:"state"`
	} `form:"address"`
}

The User struct contains an Address field, which is itself a struct. To decode it, the decoder will make a recursive call and look for those form fields prefixed by address., such as address.city and address.state.


//.. rest of form values
address.city=Boston
address.state=MA

// The decoder is iterating over the Address user field with the following values:

// fName -> `address`
// fieldVal -> Address{} (reflection value)

case reflect.Struct:
	subPrefix := fName
	if field.Anonymous {
		subPrefix = prefix
	}

	if err := decodeForm(fieldVal, form, subPrefix); err != nil {
		return err
	}

Now, the recursive call decodes the Address struct, and the prefix used to find the field values is address. Iterating over the Address fields, let’s assume it’s currently looking at the city field.


fName := cmp.Or(tagName, field.Name) // city
fType := field.Type                  // string
fKind := fType.Kind()                // reflect.String
isPtr := fKind == reflect.Ptr

if prefix != "" && !field.Anonymous {
	fName = prefix + "." + fName // address.city. This will be the key the decoder will look at form to decode the Address.City Value if any.
}

Decoding custom types

To decode custom types such as time.Time or UUIDs, we need to create a customDecoders map similar to builtInDecoders, but using reflect.Type as a key. Why? The reflect.Kind of custom types is represented as a reflect.Struct and there would be no way to distinguish between them using reflection. Therefore, a custom type could be registered and identified to decode this kind of struct field.

Let’s define the customDecoders map and add a custom type:


customDecoders = map[reflect.Type]func(string) (any, error){
	reflect.TypeOf(time.Time{}): func(value string) (any, error) {
		t, err := time.Parse("2006-01-02", value)
		if err != nil {
			return nil, fmt.Errorf("the value %q could not be parsed to time:%w", value, err)
		}
	}
}

Now, the decoder validates if the field reflect.Type matches with one of the customDecoder ones. If so, the value is decoded and set as the field value.


case reflect.Struct:
	if dec, ok := customDecoders[fType]; ok {
		values, ok := form[fName]
		if !ok || len(values) == 0 {
			continue
		}

		v, err := dec(values[0])
		if err != nil {
			return fmt.Errorf("cannot convert to %q: %w", fType, err)
		}

		fieldVal.Set(reflect.ValueOf(v))

		continue
	}

  // Decoding nested struct fields
	subPrefix := fName
	if field.Anonymous {
		subPrefix = prefix
	}

	if err := decodeForm(fieldVal, form, subPrefix); err != nil {
		return err
	}

Decoding Slices

Let’s now move on to the last form field case: slices. Slices require special handling because a single form field can hold multiple values (e.g., tags=foo&tags=bar&tags=xyz), and we also need to handle whether the element type is a built-in value, a struct, or even a pointer.

When the struct field is a slice, the decoder must determine how to process each element in the form data. There are two common scenarios:

  • The slice type is a struct or a custom type like []User, []time.Time
  • The slice type is a built-in type such as []string, []int

For the first scenario, the decoder function must look for those form values that have the format SliceFieldName[index]., if so, it must allocate a new empty slice and start to decode each struct field by making a recursive call to decodeForm and finally, append the new decoded element to the slice.


type Book struct {
	Name   string
	Author string
}

type Library struct {
	Name  string
	Books []Book `form:"books"`
}

books[0].Name=The Go Programming Language
books[0].Author=Alan A. A. Donovan
books[1].Name=Clean Code
books[1].Author=Robert C. Martin
books[2].Name=The Pragmatic Programmer
books[2].Author=Andrew Hunt

// fName -> books
// fType -> []Book{}
case reflect.Slice:
	elemType := fType.Elem()  // Book{}
	isPtr := elemType.Kind() == reflect.Ptr // false
	baseType := elemType // Book{}
	if isPtr {
		baseType = elemType.Elem()
	}

	if baseType.Kind() == reflect.Struct { // Boo{} == reflect.Struct -> true
		slice := reflect.MakeSlice(fType, 0, 0) // make([]Book, 0, 0)
		index := 0
		for {
			subKey := fmt.Sprintf("%s[%d]", fName, index) // "books[0]"
			var found bool
			for k := range form {
				if strings.HasPrefix(k, subKey+".") {
					found = true
					break
				}
			}
			if !found {
				break
			}

			el := reflect.New(baseType).Elem() // Book{}
			// decoding Book struct fields looking at those form keys with the prefix `books[0].`
			if err := decodeForm(el, form, subKey); err != nil {
				return err
			}

			// Append new Book to []Book
			if isPtr {
				slice = reflect.Append(slice, el.Addr())
			} else {
				slice = reflect.Append(slice, el)
			}
			index++
		}
		fieldVal.Set(slice)
		continue
	}

The second scenario is simpler; if the slice type is a built-in type, the process must create the new slice and then iterate over each form value.


type ShoppingList struct {
	Owner string
	Items []string `form:"items"`
}

items=eggs
items=bananas
items=milk

// fName -> items
// fType -> []string{}

values, ok := form[fName] // [eggs, bananas, milk]
if !ok {
	continue
}


slice := reflect.MakeSlice(fType, len(values), len(values)) // make([]string, 3, 3)
for i, val := range values {
	el := slice.Index(i) // slice[0] -> string
	if isPtr {
		ptr := reflect.New(baseType).Elem()
		if err := decodeField(ptr, val); err != nil {
			return err
		}
		ref := reflect.New(baseType)
		ref.Elem().Set(ptr)
		el.Set(ref)
	} else {
		if err := decodeField(el, val); err != nil {
			return err
		}
	}
}
fieldVal.Set(slice)

You can view the final version of the decoder here: form decoder

Final thoughts

Writing a custom form decoder in Go is not as complicated as it may look. By breaking it down into clear steps—checking struct fields, handling pointers, supporting built-in and custom types, and adding recursion for structs and slices—you can build something both simple and flexible.

The main benefit is having full control: you avoid third-party dependencies, keep only the features you need, and make the decoder fit your application instead of the other way around.

This approach covers the essentials, but you can extend it later with validation, richer error messages, or support for more types. More importantly, building it yourself gives you a deeper understanding of reflection and type handling in Go, which is useful in many other areas too.

Share this post: