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:
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:
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:
- If you find yourself writing repeated boilerplate code for different data types. Examples of this might be common operations on slices, maps or channels — or helpers for carrying out validation checks or test assertions on different data types.
- When you are writing code and find yourself reaching for the
any
(emptyinterface{}
) type. An example of this might be when you are creating a data structure (like a queue, cache or linked list) which needs to operate on different types.
In contrast, you probably don’t want to use generics:
- If it makes your code harder to understand or less clear.
- If all the types that you need to work with have a common set of methods — in which case it’s better to define and use a normal
interface
type instead. - Just because you can. Prefer instead write non-generic code by default, and switch to a generic version later only if it is actually needed.