JavaRush /Courses /Go SELF /Validation after Unmarshal: required fields

Validation after Unmarshal: required fields

Go SELF
Level 45, Lesson 3
Available

1. Why you need validation after Unmarshal

If you’ve just started working with JSON, it’s very easy to fall into a trap: “Well, Unmarshal didn’t complain — so the data must be good.” Unfortunately (or fortunately — otherwise we’d ship bugs even faster), Unmarshal checks only basic things: JSON syntax and that the types roughly match. Domain rules, however, are your responsibility.

Imagine: the JSON is correct, braces are in place, quotes are where they should be — but title is empty, priority equals 999, and the date is “in the last century”. From Unmarshal’s point of view, this may be perfectly valid JSON. From your program’s point of view, it’s “data that makes you want to close the laptop and go live in the woods”.

A strong habit worth developing: decode (parsing the format) and validate (checking the meaning) are two different stages. And between them there’s usually a small normalization stage (for example, strings.TrimSpace).

Broken JSON vs. bad data

When a program receives JSON from outside (a file, HTTP, stdin, another service), there are two fundamentally different kinds of trouble. If you mix them into one pile, you’ll get weird UX: the user will see “invalid input”, while you’ll be thinking “why is everything crashing again?” and “where exactly did it break?”.

Below is a short table that helps keep this separation in your head:

Situation Example Who’s at fault What to usually do
Broken JSON (format error) {"title":} or an extra comma the sender broke the format return a decoding error (Unmarshal returned an error)
JSON is valid, but the data is bad (meaning/semantic error) {"title":"", "priority":999} the sender sent nonsense values return a validation error (your rule checks)

Key takeaway: decoding errors and validation errors are best handled separately, so you can give a clearer answer to a human (or to logs) about what exactly went wrong.

The decode → normalize → validate pipeline

When you write code “on a real project”, it usually turns into the same ritual. Nothing mystical — just discipline: first you read/decode, then you bring values into a normal form, then you check the rules.

It’s convenient to picture it as a small flowchart:

flowchart LR
    A[JSON bytes] --> B[Unmarshal v DTO]
    B --> C[Normalize: TrimSpace i t.p.]
    C --> D[Validate: required + ogranicheniya]
    D --> E[Dal'she po logike programmy]

Why this is a good style:

  • you don’t mix format problems and semantic problems;
  • you don’t try to “heal” broken JSON (that’s almost always a bad idea);
  • you can reuse Validate() in tests and in different parts of the application.

2. Required fields and zero values

Where Unmarshal hides a missing field

When people say “required field”, they most often mean: “you can’t continue without this field”. The problem is that in Go, types have a zero value. For string it’s "", for int it’s 0, for bool it’s false, for time.Time it’s the “zero time”, and for a pointer it’s nil.

And when a field is absent in JSON, Unmarshal… just leaves the zero value. So “the field didn’t arrive” and “the field arrived but equals zero” can sometimes look the same. For strings this is usually not a big deal (an empty string almost never satisfies a required constraint), but for numbers it can be important to tell the difference.

Let’s start with a very down-to-earth DTO for creating a task:

package main

import "time"

type CreateTaskDTO struct {
	Title    string     `json:"title"`
	Priority int        `json:"priority"`
	DueAt    *time.Time `json:"due_at,omitempty"`
}

Here you can already see two typical solutions:

Title string — a required string (most often).
DueAt *time.Time — optional time: nil means “no date”, and this naturally matches JSON null and/or a missing field.

But Priority int is more interesting. If the priority field is missing, Priority will stay 0. That can be fine if you agreed in advance: “if priority isn’t set, we consider it 0 or 1”. Or it can be a problem if you need strict rules: “priority is required and must be in 1..5”.

When you should use pointers

If it’s critical for you to distinguish “didn’t arrive” from “arrived as 0”, you use *int. This sounds a bit scarier than it really is:

package main

type CreateTaskDTO struct {
	Title    string `json:"title"`
	Priority *int   `json:"priority"`
}

Then:

  • Priority == nil means “the key wasn’t present” or “the value was null”;
  • Priority != nil means the value arrived, and you can validate the range.

3. Implementing Validate and Normalize

Validate() error with early return

When you’re just starting out, the clearest contract is “found a problem → return an error immediately”. It’s quick to write, easy to read, and almost always enough.

Let’s start simple: required for Title and a range check for Priority.

package main

import (
	"errors"
	"fmt"
	"strings"
)

type CreateTaskDTO struct {
	Title    string `json:"title"`
	Priority int    `json:"priority"`
}

func (d CreateTaskDTO) Validate() error {
	if strings.TrimSpace(d.Title) == "" {
		return errors.New("title is required")
	}
	if d.Priority < 1 || d.Priority > 5 {
		return fmt.Errorf("priority must be 1..5, got %d", d.Priority)
	}
	return nil
}

There are a few important “grown-up” habits here:

  • we check Title not just against "", but via TrimSpace, so that " " is not considered valid;
  • in Go, error text is usually written in lowercase, without a period — later it composes nicely into longer errors and logs.

Normalization as a separate step

Very often you want to do this: “let’s also do TrimSpace inside Validate(), and strings.ToLower, and replace double spaces…”. Sometimes that’s fine, but more often it’s beneficial to keep one idea straight: validation checks; normalization changes.

To avoid mixing these roles, you can make a separate Normalize() method (it doesn’t have to be a method — it can be a function, but a method reads nicely for a DTO):

package main

import "strings"

type CreateTaskDTO struct {
	Title string `json:"title"`
}

func (d *CreateTaskDTO) Normalize() {
	d.Title = strings.TrimSpace(d.Title)
}

Notice the *CreateTaskDTO: normalization mutates the object, so it’s logical to work through a pointer.

Now validation becomes simpler: it checks data that is already “normalized”.

Multiple errors at once: errors.Join

Sometimes it’s convenient to return not the first error, but all problems found at once. For example, a user fills out a form, and you want to say in one pass: “title is empty, priority is out of range, due_at is in the past”. This saves iterations of “fixed one thing → got the second error → fixed the second thing → got the third error”.

In Go, errors are values, and you can collect them as data. And the standard library provides a tool to glue multiple errors into one: errors.Join.

Here’s a neat validation variant that collects everything it finds:

package main

import (
	"errors"
	"fmt"
	"strings"
)

type CreateTaskDTO struct {
	Title    string `json:"title"`
	Priority int    `json:"priority"`
}

func (d CreateTaskDTO) Validate() error {
	var errs []error

	if strings.TrimSpace(d.Title) == "" {
		errs = append(errs, errors.New("title is required"))
	}
	if d.Priority < 1 || d.Priority > 5 {
		errs = append(errs, fmt.Errorf("priority must be 1..5, got %d", d.Priority))
	}

	return errors.Join(errs...)
}

It’s important to understand the behavior here: if there are no errors, errs is empty, and errors.Join(errs...) returns nil. This is super convenient: the calling code stays the same — if err != nil.

4. Useful nuances when parsing JSON

Diagnostics: *json.SyntaxError and errors.As

When Unmarshal fails, the error can be different things: syntax, type mismatch, unexpected end of input, and so on. Sometimes you want to provide a more understandable message. For example: “you have a JSON syntax error near this spot”.

In the standard library’s encoding/json package there is an error type json.SyntaxError, which has Offset — a byte position where the parser realized things went wrong.

And to neatly check “is this a SyntaxError or not”, it’s convenient to use errors.As, which can work with error chains (this is especially useful if you wrapped the error with context using fmt.Errorf("...: %w", err)).

Mini example:

package main

import (
	"encoding/json"
	"errors"
	"fmt"
)

func main() {
	data := []byte(`{"title":}`) // broken JSON

	var v map[string]any
	err := json.Unmarshal(data, &v)
	if err == nil {
		return
	}

	var syn *json.SyntaxError
	if errors.As(err, &syn) {
		fmt.Println("bad JSON syntax at byte:", syn.Offset) // bad JSON syntax at byte: ...
		return
	}

	fmt.Println("other JSON error:", err)
}

The point is that you can make user-facing messages more “human”. But don’t turn this into a 12-season detective series: most of the time it’s enough to simply separate a decode error from a validation error.

Why omitempty does not make a field optional

A very common beginner confusion looks like this: “I’ll add omitempty, and the field will become optional”. In reality, omitempty is about encoding (Marshal), not about reading and not about validation.

That is, omitempty says: “when we turn a Go struct into JSON, don’t include the field if it’s empty”. But when you read JSON via Unmarshal, omitempty does not force JSON to “contain the field” or “not contain the field”.

Required rules always live in your validation. This is not a weakness of Go — it’s its honesty: data format and data meaning are separated.

5. Example: decode → normalize → validate

Now let’s put everything together into one small scenario: we received JSON for task creation, parsed it into a DTO, ran normalization, checked required constraints and limits, and then either print “ok” or explain what’s wrong.

First, the DTO:

package main

import "time"

type CreateTaskDTO struct {
	Title    string     `json:"title"`
	Priority int        `json:"priority"`
	DueAt    *time.Time `json:"due_at,omitempty"`
}

Then normalize + validate (keep it short, but readable):

package main

import (
	"errors"
	"fmt"
	"strings"
	"time"
)

func (d *CreateTaskDTO) Normalize() {
	d.Title = strings.TrimSpace(d.Title)
}

func (d CreateTaskDTO) Validate(now time.Time) error {
	if d.Title == "" {
		return errors.New("title is required")
	}
	if d.Priority < 1 || d.Priority > 5 {
		return fmt.Errorf("priority must be 1..5, got %d", d.Priority)
	}
	if d.DueAt != nil && d.DueAt.Before(now) {
		return errors.New("due_at must not be in the past")
	}
	return nil
}

Note the now time.Time parameter: it’s a small trick that makes the code testable and predictable (you explicitly provide the “current time”, instead of calling time.Now() inside Validate()).

And finally, main, where the whole pipeline is visible:

package main

import (
	"encoding/json"
	"fmt"
	"time"
)

func main() {
	data := []byte(`{"title":"  Learn JSON  ","priority":3}`)

	var dto CreateTaskDTO
	if err := json.Unmarshal(data, &dto); err != nil {
		fmt.Println("decode error:", err)
		return
	}

	dto.Normalize()

	now := time.Date(2026, 1, 16, 0, 0, 0, 0, time.UTC)
	if err := dto.Validate(now); err != nil {
		fmt.Println("validation error:", err)
		return
	}

	fmt.Println("ok") // ok
}

If you change the input to something like {"title":" ","priority":999}, the JSON will be syntactically valid, but you’ll get a validation error rather than a decoding error — and that is exactly the separation we were aiming for.

6. Common mistakes

Mistake No. 1: assuming Unmarshal validates required fields.
Unmarshal checks syntax and basic type compatibility. But “title must not be empty”, “priority must be 1..5”, “due_at can’t be in the past” are semantic rules, and you must validate them separately. If you don’t, your program will accept valid JSON with garbage values and break later, in unexpected places.

Mistake No. 2: checking Title == "" and forgetting about whitespace.
User input often contains spaces, tabs, and other joys. A " " string is technically not empty, but for a required field it’s usually useless. Normalizing with strings.TrimSpace before validation solves this class of problems with almost no effort.

Mistake No. 3: trying to “fix” broken JSON instead of returning an error.
Sometimes you want to be “smart”: if the JSON is broken, try to “guess” what was meant. In practice this almost always creates unpredictability. If JSON is syntactically invalid, it’s better to honestly return a decoding error. And if you want a clearer message — you can detect *json.SyntaxError via errors.As.

Mistake No. 4: mixing decode, normalize, and validate into one huge function.
Such code quickly turns into “spaghetti”: you’re simultaneously handling Unmarshal errors, trimming whitespace, checking ranges, and somewhere between the lines the intent gets lost. Splitting into stages (Unmarshal → Normalize → Validate) makes the program simpler, even if it feels like there are a few more lines.

Mistake No. 5: returning only “invalid input” without saying what exactly is wrong.
Errors in Go are values, and they should carry meaning. If you write return errors.New("invalid input") for everything, you deprive yourself (and the user) of useful information. It’s much better to write at least “title is required” or “priority must be 1..5”. This isn’t pedantry — it’s a way to fix issues quickly without guessing.

1
Task
Go SELF, level 45, lesson 3
Locked
Title validation
Title validation
1
Task
Go SELF, level 45, lesson 3
Locked
Task priority
Task priority
1
Task
Go SELF, level 45, lesson 3
Locked
Task deadline
Task deadline
1
Task
Go SELF, level 45, lesson 3
Locked
Error checklist
Error checklist
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION