Let's Go Processing forms › Creating validation helpers
Previous · Contents · Next
Chapter 7.5.

Creating validation helpers

OK, so we’re now in the position where our application is validating the form data according to our business rules and gracefully handling any validation errors. That’s great, but it’s taken quite a bit of work to get there.

And while the approach we’ve taken is fine as a one-off, if your application has many forms then you can end up with quite a lot of repetition in your code and validation rules. Not to mention, writing code for validating forms isn’t exactly the most exciting way to spend your time.

So to help us with validation throughout the rest of this project, we’ll create our own small internal/validator package to abstract some of this behavior and reduce the boilerplate code in our handlers. We won’t actually change how the application works for the user at all; it’s really just a refactoring of our codebase.

Adding a validator package

If you’re coding-along, please go ahead and create the following directory and file on your machine:

$ mkdir internal/validator
$ touch internal/validator/validator.go

Then in this new internal/validator/validator.go file add the following code:

File: internal/validator/validator.go
package validator

import (
    "slices"
    "strings"
    "unicode/utf8"
)

// Define a new Validator struct which contains a map of validation error messages 
// for our form fields.
type Validator struct {
    FieldErrors map[string]string
}

// Valid() returns true if the FieldErrors map doesn't contain any entries.
func (v *Validator) Valid() bool {
    return len(v.FieldErrors) == 0
}

// AddFieldError() adds an error message to the FieldErrors map (so long as no
// entry already exists for the given key).
func (v *Validator) AddFieldError(key, message string) {
    // Note: We need to initialize the map first, if it isn't already
    // initialized.
    if v.FieldErrors == nil {
        v.FieldErrors = make(map[string]string)
    }

    if _, exists := v.FieldErrors[key]; !exists {
        v.FieldErrors[key] = message
    }
}

// CheckField() adds an error message to the FieldErrors map only if a
// validation check is not 'ok'.
func (v *Validator) CheckField(ok bool, key, message string) {
    if !ok {
        v.AddFieldError(key, message)
    }
}

// NotBlank() returns true if a value is not an empty string.
func NotBlank(value string) bool {
    return strings.TrimSpace(value) != ""
}

// MaxChars() returns true if a value contains no more than n characters.
func MaxChars(value string, n int) bool {
    return utf8.RuneCountInString(value) <= n
}

// PermittedValue() returns true if a value is in a list of specific permitted
// values.
func PermittedValue[T comparable](value T, permittedValues ...T) bool {
    return slices.Contains(permittedValues, value)
}

To summarize this:

In the code above we’ve defined a Validator struct type which contains a map of error messages. The Validator type provides a CheckField() method for conditionally adding errors to the map, and a Valid() method which returns whether the errors map is empty or not. We’ve also added NotBlank() , MaxChars() and PermittedValue() functions to help us perform some specific validation checks

Conceptually this Validator type is quite basic, but that’s not a bad thing. As we’ll see over the course of this book, it’s surprisingly powerful in practice and gives us a lot of flexibility and control over validation checks and how we perform them.

Using the helpers

Alright, let’s start putting the Validator type to use!

We’ll head back to our cmd/web/handlers.go file and update it to embed a Validator struct in our snippetCreateForm struct, and then use this to perform the necessary validation checks on the form data.

Like so:

File: cmd/web/handlers.go
package main

import (
    "errors"
    "fmt"
    "net/http"
    "strconv"

    "snippetbox.alexedwards.net/internal/models"
    "snippetbox.alexedwards.net/internal/validator" // New import
)

...

// Remove the explicit FieldErrors struct field and instead embed the Validator
// struct. Embedding this means that our snippetCreateForm "inherits" all the
// fields and methods of our Validator struct (including the FieldErrors field).
type snippetCreateForm struct {
    Title               string 
    Content             string 
    Expires             int    
    validator.Validator
}

func (app *application) snippetCreatePost(w http.ResponseWriter, r *http.Request) {
    err := r.ParseForm()
    if err != nil {
        app.clientError(w, http.StatusBadRequest)
        return
    }

    expires, err := strconv.Atoi(r.PostForm.Get("expires"))
    if err != nil {
        app.clientError(w, http.StatusBadRequest)
        return
    }

    form := snippetCreateForm{
        Title:   r.PostForm.Get("title"),
        Content: r.PostForm.Get("content"),
        Expires: expires,
        // Remove the FieldErrors assignment from here.
    }

    // Because the Validator struct is embedded by the snippetCreateForm struct,
    // we can call CheckField() directly on it to execute our validation checks.
    // CheckField() will add the provided key and error message to the
    // FieldErrors map if the check does not evaluate to true. For example, in
    // the first line here we "check that the form.Title field is not blank". In
    // the second, we "check that the form.Title field has a maximum character
    // length of 100" and so on.
    form.CheckField(validator.NotBlank(form.Title), "title", "This field cannot be blank")
    form.CheckField(validator.MaxChars(form.Title, 100), "title", "This field cannot be more than 100 characters long")
    form.CheckField(validator.NotBlank(form.Content), "content", "This field cannot be blank")
    form.CheckField(validator.PermittedValue(form.Expires, 1, 7, 365), "expires", "This field must equal 1, 7 or 365")

    // Use the Valid() method to see if any of the checks failed. If they did,
    // then re-render the template passing in the form in the same way as
    // before.
    if !form.Valid() {
        data := app.newTemplateData(r)
        data.Form = form
        app.render(w, r, http.StatusUnprocessableEntity, "create.tmpl", data)
        return
    }

    id, err := app.snippets.Insert(form.Title, form.Content, form.Expires)
    if err != nil {
        app.serverError(w, r, err)
        return
    }

    http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther)
}

So this is shaping up really nicely.

We’ve now got an internal/validator package with validation rules and logic that can be reused across our application, and it can easily be extended to include additional rules in the future. Both form data and errors are neatly encapsulated in a single snippetCreateForm struct — which we can easily pass to our templates — and the syntax for displaying error messages and re-populating the data in our templates is simple and consistent.

If you like, go ahead and re-run the application now. All being well, you should find that the form and validation rules are working correctly and in exactly the same manner as before.


Additional information

Generics

Go 1.18 was the first version of the language to support generics — also known by the more technical name of parametric polymorphism. Very broadly, generics allow you to write code that works with different concrete types.

For example, in older versions of Go, if you wanted to count how many times a particular value appears in a []string slice and an []int slice you would need to write two separate functions — one function for the []string type and another for the []int. A bit like this:

// Count how many times the value v appears in the slice s.
func countString(v string, s []string) int {
    count := 0
    for _, vs := range s {
        if v == vs {
            count++
        }
    }
    return count
}

func countInt(v int, s []int) int {
    count := 0
    for _, vs := range s {
        if v == vs {
            count++
        }
    }
    return count
}

Now, with generics, it’s possible to write a single count() function that will work for []string, []int, or any other slice of a comparable type. The code would look like this:

func count[T comparable](v T, s []T) int {
    count := 0
    for _, vs := range s {
        if v == vs {
            count++
        }
    }
    return count
}

If you’re not familiar with the syntax for generic code in Go, there’s a lot of great information available which explains how generics works and walks you through the syntax for writing generic code.

To get up to speed, I highly recommend reading the official Go generics tutorial, and also watching the first 15 minutes of this video to help consolidate what you’ve learnt.

Rather than duplicating that same information here, instead I’d like to talk briefly about a less common (but just as important!) topic: when to use generics.

For now at least, you should aim to use generics judiciously and cautiously.

I know that might sound a bit boring, but generics are a relatively new language feature and best-practices around writing generic code are still being established. If you work on a team, or write code in public, it’s also worth keeping in mind that not all other Go developers will necessarily be familiar with how generic code works.

You don’t need to use generics, and it’s OK not to.

But even with those caveats, writing generic code can be really useful in certain scenarios. Very generally speaking, you might want to consider it:

In contrast, you probably don’t want to use generics: