JavaRush /Courses /Go SELF /Review — errors and early returns in Go

Review — errors and early returns in Go

Go SELF
Level 20, Lesson 2
Available

1. Go errors are part of normal code

If you came to Go from languages where errors “fly” on their own, then for a while Go feels strange: “why do I have to check err after every step?!”. In reality, Go bets on predictability: you see right in the code where an operation can fail, and you immediately decide what to do. This is not punishment; it’s a way to avoid playing detective in production.

In Go, an error is a value of type error. It has only one “required” method: Error() string. That is, an error is any object that can explain itself as a string. So the basic rule for reading Go code is: if a function returns error, the author is honestly warning you: “I might not manage it.”

It’s important to lock this in: checking err != nil is not “nitpicking,” it’s part of controlling the execution flow. An error in Go is the same kind of turn in the road as if or for. Only instead of “if the user pressed the button” we have “if the operation failed.”

2. The function contract (T, error) and the point of early returns

When you see a signature like func X(...) (T, error), you’re looking at a contract. The contract says: “in the best world I return T and nil, and in the worst world I return the zero value for T and a non-nil error.” This is not an accidental tradition; it’s an agreement that makes code readable: first check the error, then use the result.

Early return is a style where we don’t build a tower of nested ifs; instead, at each problematic point we quickly exit outward. This makes the function “linear”: you read it top-to-bottom like instructions, not like a maze.

Look at a minimal pattern:


package main

import (
	"fmt"
	"strconv"
)

func parsePort(s string) (int, error) {
	n, err := strconv.Atoi(s)
	if err != nil {
		return 0, err // early exit: there’s no point continuing
	}
	if n <= 0 {
		return 0, fmt.Errorf("port must be positive, got %d", n)
	}
	return n, nil
}

func main() {
	p, err := parsePort("abc")
	fmt.Println(p, err) // 0 strconv.Atoi: parsing "abc": invalid syntax
}

There’s no “branching for the sake of branching” here: any problem immediately ends the function.

3. Guard clauses: how to write if linearly

When a beginner wants to “handle everything correctly,” they often start building code like this: “if there’s no error, then if the condition, then if another condition…”. The result is a nested “tree,” where it’s easy to forget where you are.

Early returns solve this with guard clauses (protective checks): “if it’s bad — exit.” In good Go code, you’ll often see exactly this style.

Let’s compare two approaches for the same logic: “read a number, check the range, return”:

package main

import (
	"fmt"
	"strconv"
)

func parseAgeBad(s string) (int, error) {
	n, err := strconv.Atoi(s)
	if err == nil {
		if n >= 0 {
			if n <= 150 {
				return n, nil
			}
			return 0, fmt.Errorf("age too big: %d", n)
		}
		return 0, fmt.Errorf("age must be non-negative: %d", n)
	}
	return 0, err
}

func parseAgeGood(s string) (int, error) {
	n, err := strconv.Atoi(s)
	if err != nil {
		return 0, err
	}
	if n < 0 {
		return 0, fmt.Errorf("age must be non-negative: %d", n)
	}
	if n > 150 {
		return 0, fmt.Errorf("age too big: %d", n)
	}
	return n, nil
}

func main() {
	age, _ := parseAgeGood("42")
	fmt.Println(age) // 42
}

The second version reads like a checklist. And this isn’t “personal taste”: when code is easier to read, it gets broken less often.

4. Errors: create, add context, don’t lose the cause

Errors in real programs almost never live “alone.” You call parse, then validate, then save, and if something breaks you want to know: at which step and with what data. For this, we add context. In Go, this is done either by creating a new error or by wrapping.

errors.New and fmt.Errorf

errors.New("...") is the simplest way to make an error when you don’t need parameters. fmt.Errorf("...%d...", x) is for when you need textual context.

package main

import (
	"errors"
	"fmt"
)

func mustBeEven(n int) error {
	if n%2 != 0 {
		return errors.New("number must be even")
	}
	return nil
}

func main() {
	err := mustBeEven(3)
	fmt.Println(err) // number must be even
}

Wrapping via %w

If you do fmt.Errorf("something: %v", err), you lose the cause as a structure: only the text remains. If you do fmt.Errorf("something: %w", err), you preserve the cause and build an “error chain” that you can analyze with errors.Is and errors.As.

package main

import (
	"errors"
	"fmt"
)

var ErrBadInput = errors.New("bad input")

func parseSomething(s string) error {
	if s == "" {
		return ErrBadInput
	}
	return nil
}

func run(s string) error {
	if err := parseSomething(s); err != nil {
		return fmt.Errorf("run: %w", err)
	}
	return nil
}

func main() {
	err := run("")
	fmt.Println(err) // run: bad input
}

It looks similar, but the difference is huge: ErrBadInput “hides inside” and remains accessible.

When you should not use %w

There’s a subtle point: by wrapping an error, you’re promising the calling code that it will be able to “pull out” the inner cause. Sometimes this is good, sometimes it’s an unnecessary leak of details. For example, if you don’t want the outer layer to depend on internal errors, you can choose not to “propagate” the cause as a cause, and keep it only as text.

The idea is simple: wrap when you want to allow analyzing the cause; don’t wrap when it turns implementation details into a public contract.

5. errors.Is and errors.As: don’t check errors by strings

Checking errors like this: if err.Error() == "not found" is a bad habit. First, the message can change. Second, it can be localized. Third, you turn your program logic into “text parsing,” and that usually ends sadly.

In Go, for semantic checks there is errors.Is. It can look inside the chain created by wrapping errors.

package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("not found")

func find(id int) error {
	if id == 0 {
		return ErrNotFound
	}
	return nil
}

func load(id int) error {
	if err := find(id); err != nil {
		return fmt.Errorf("load id=%d: %w", id, err)
	}
	return nil
}

func main() {
	err := load(0)

	fmt.Println(errors.Is(err, ErrNotFound)) // true
}

errors.As is a “smart type assertion” for errors. It’s important to remember that when an error has a specific type (for example, a “validation error with fields”), errors.As lets you extract that type from the chain.

6. Multiple errors and an execution-flow diagram

errors.Join: when there are several errors

In real life it happens that input data is wrong for several reasons at once: an empty name and a negative age, an invalid format and an out-of-range value. Returning only the “first one you hit” can be inconvenient: the user fixes one thing, gets a second error, then a third — and curses your UI.

errors.Join lets you combine multiple errors into one without manually turning them into a single string. This is especially handy for validation.

package main

import (
	"errors"
	"fmt"
)

func validateTask(title string, priority int) error {
	var errs []error

	if title == "" {
		errs = append(errs, errors.New("title is empty"))
	}
	if priority < 1 || priority > 5 {
		errs = append(errs, errors.New("priority must be in [1..5]"))
	}
	return errors.Join(errs...)
}

func main() {
	fmt.Println(validateTask("", 10))
	// title is empty
	// priority must be in [1..5]
}

Note: if errs is empty, errors.Join(errs...) will return nil. That’s convenient: “no problems — no error.”

Execution flow as a diagram

When you’re just learning, it’s useful to picture error handling as a simple flowchart: at each step you either succeed, or you immediately exit. This straightforward model saves you from half the bugs of the kind “we kept executing after a failure.”

flowchart TD
    A[Function start] --> B[Step 1: call]
    B -->|err == nil| C[Step 2: call]
    B -->|err != nil| X[return ...err]
    C -->|err == nil| D[Step 3: work with result]
    C -->|err != nil| X
    D --> E[return result, nil]

That’s why Go code often looks “verbose,” yet it’s usually very predictable: you always see where a failure can happen and what comes next.

7. Example: TaskBox and early returns

Now we’ll assemble a small “skeleton” of a TaskBox console app. It will store tasks in memory: a task has an id, a title, and a “done” flag. We won’t use full modeling, so we’ll get by with a map and a set pattern.

Mini data model using maps

As a “store,” we’ll take two maps: the first stores task titles, the second is a set of completed tasks.

package main

type TaskID int

type TaskStore struct {
	nextID TaskID
	title  map[TaskID]string
	done   map[TaskID]struct{}
}

func NewTaskStore() *TaskStore {
	return &TaskStore{
		nextID: 1,
		title:  make(map[TaskID]string),
		done:   make(map[TaskID]struct{}),
	}
}

Yes, there’s a struct here — but only as a “field container.” If you want to be extremely strict, you could split this into several variables, but this is clearer, and the error-handling logic doesn’t change.

Adding a task: early returns and validation

Let’s make a function Add(title string) (TaskID, error) and immediately exit on problems.

package main

import (
	"errors"
	"strings"
)

var ErrEmptyTitle = errors.New("title is empty")

func (s *TaskStore) Add(title string) (TaskID, error) {
	title = strings.TrimSpace(title)
	if title == "" {
		return 0, ErrEmptyTitle
	}

	id := s.nextID
	s.nextID++

	s.title[id] = title
	return id, nil
}

Here, the early return protects the store invariant. If the title is empty, we don’t even start to “partially” mutate state.

Mark as done: not found and wrapping with context

Now we want Done(id TaskID) error. There are at least two errors here: the task doesn’t exist, or something internal (in our case there’s almost nothing internal, but we still show the pattern).

package main

import (
	"errors"
	"fmt"
)

var ErrTaskNotFound = errors.New("task not found")

func (s *TaskStore) Done(id TaskID) error {
	if id <= 0 {
		return fmt.Errorf("done: id must be positive, got %d", id)
	}

	if _, ok := s.title[id]; !ok {
		return fmt.Errorf("done id=%d: %w", id, ErrTaskNotFound)
	}

	s.done[id] = struct{}{}
	return nil
}

The key point: we return ErrTaskNotFound wrapped. Then outer code can do errors.Is(err, ErrTaskNotFound) and make a decision (for example, “show the user ‘no such task’”).

Parsing TaskID from a string

Often an ID comes in as a string. In Go we typically parse via strconv.Atoi and don’t continue on error.

package main

import (
	"fmt"
	"strconv"
	"strings"
)

func ParseTaskID(s string) (TaskID, error) {
	s = strings.TrimSpace(s)

	n, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("parse task id %q: %w", s, err)
	}
	if n <= 0 {
		return 0, fmt.Errorf("task id must be positive, got %d", n)
	}
	return TaskID(n), nil
}

Notice %q: it’s handy when the string is “dirty,” with spaces or unexpected characters. And even more importantly: we use %w so we don’t lose the original parsing error cause.

A “command” as a function: early returns in the handler

Let’s make a done command handler that receives an argument as a string and returns an error. This is a convenient style: main doesn’t know the details; it just prints the error.

package main

import "fmt"

func runDone(store *TaskStore, idArg string) error {
	id, err := ParseTaskID(idArg)
	if err != nil {
		return err
	}

	if err := store.Done(id); err != nil {
		return fmt.Errorf("command done: %w", err)
	}

	return nil
}

Here you can clearly see why early returns matter: if the ID didn’t parse, we don’t try to “do done.” If Done returned an error, we added context (“which command failed”) and exited.

Centralized error handling in main

Finally, let’s show a minimal main that runs a scenario and prints an error. We’re not discussing exit codes and stderr/stdout “by the protocol” here (that’s a separate big topic), so we keep it simple.

package main

import (
	"errors"
	"fmt"
)

func main() {
	store := NewTaskStore()

	id, err := store.Add("buy_milk")
	if err != nil {
		fmt.Println("error:", err)
		return
	}

	err = runDone(store, fmt.Sprint(id))
	if err != nil {
		if errors.Is(err, ErrTaskNotFound) {
			fmt.Println("user-friendly: no such task")
			return
		}
		fmt.Println("error:", err)
		return
	}

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

Notice how neatly the roles are separated: inside we preserve causes and context, and outside we can produce a clear message.

8. Common mistakes

Mistake #1: keep executing after err != nil.
This usually looks like: you got an error, printed it “for show,” and moved on as if everything were fine. The problem is that almost always after an error the data is either invalid, partially filled, or missing entirely. An early return enforces discipline: “if the step failed — the scenario is over.”

Mistake #2: lose the error cause by using %v instead of %w.
fmt.Errorf("...: %v", err) makes nice text, but breaks semantics: later errors.Is and errors.As won’t be able to find the original cause in the chain, because there won’t be a chain. If it’s important that the calling code can distinguish “not found” from “bad input,” the cause must be preserved via %w.

Mistake #3: check errors via err.Error() and tie logic to text.
Error text is meant for humans. For logic, use sentinel errors and errors.Is, and to extract details use errors.As. When code starts comparing strings, it becomes fragile: any change in wording turns into a hidden bug.

Mistake #4: return “partial success” without a clear contract.
For example, a function returned id=10 and err != nil. A beginner sometimes thinks: “well, there’s an error, but the id is useful.” This is almost always a path to confusion: the calling code doesn’t understand whether it can use id. If you return an error, try to return the zero value for the result, unless the contract explicitly says otherwise.

Mistake #5: use panic as a normal logic branch.
panic is an emergency mode, not “another way to report an error.” For expected problems (bad input, not found, access denied) you should return an error. Otherwise you turn ordinary situations into program crashes and lose control over the flow.

1
Task
Go SELF, level 20, lesson 2
Locked
Sensor division
Sensor division
1
Task
Go SELF, level 20, lesson 2
Locked
Age in the registration form
Age in the registration form
1
Task
Go SELF, level 20, lesson 2
Locked
Command calculator
Command calculator
1
Task
Go SELF, level 20, lesson 2
Locked
Profile validation
Profile validation
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION