JavaRush /Courses /Go SELF /error as an interface — nil / non-nil

error as an interface — nil / non-nil

Go SELF
Level 16, Lesson 0
Available

1. Errors in Go: values and the error interface

If you come to Go after languages that have exceptions, the first culture shock usually looks like this: “Why do I need to write an error check after every other call?”. That feeling is normal: your brain is used to an error being something supernatural, while in Go it is as down-to-earth as possible — literally a variable you can assign, return from functions, and print.

One of the key slogans of the Go world sounds like this: errors are values. That is why Go encourages an explicit check of err, rather than “throwing” something up the stack where later someone somewhere tries to “catch” an exception. This style makes control flow more predictable: you can see where the program might “stumble”, and you can see what exactly will happen at that point.

In practice this means a simple thing: if a function can fail (for example, it parses a number, reads input, opens a file), it usually returns a second result of type error. And then you decide what to do: terminate the program, ask the user to re-enter, choose a default value, skip the record and continue. But the decision is made by code, not by an “exception system”.

Type error: what it really is

Now for an important point: error is a type, and not just any type, but an interface type. In the Go standard library it is defined roughly like this:

type error interface {
    Error() string
}

If you haven’t studied interfaces “systematically” yet — that’s fine. For today’s lecture it’s enough to understand an interface very pragmatically: an interface is a contract: “a value has certain methods”. Here the contract is minimal: if a value has a method Error() that returns a string, then that value can be used as an error.

And this is where the good news begins: it explains several “weird” things at once that usually scare beginners.

  • First, error is not a string. It can provide a string via Error(), but by itself it is not a string.
  • Second, errors can have different “internal” (concrete) types: strconv.Atoi returns one specific error type, fmt.Scan may return another, and so on. But externally we hold them in a variable of type error because the general contract is enough for us: “you can get text”.
  • Third, that is exactly why fmt can print errors nicely: it knows the error has an Error() method and can call it.

To see the idea “interface = method contract” in a very tangible way, here is a tiny demonstration:

package main

import "fmt"

type myError struct{}

func (myError) Error() string { // implement the error contract
	return "something went wrong"
}

func main() {
	var err error = myError{}
	fmt.Println(err) // something went wrong
}

There is zero magic here: myError has an Error() method, so it can be stored in a variable of type error.

nil as a sign of success: the zero value for error

Now for the most practical rule in Go code: nil in an error means success. And non-nil means an error occurred. This is not a “special operator”, but a convention that the entire Go style is built on.

It’s important that the zero value (default value) for a variable of type error is exactly nil. That is, if you declare var err error, then “by default” you have “no error”.

package main

import "fmt"

func main() {
	var err error // zero value for error is nil
	fmt.Println(err == nil) // true
	fmt.Println(err)        // <nil>
}

Pay attention to the second print: fmt.Println(err) prints <nil>. This is not the string “nil”, but a special formatting of a nil value.

Now let’s connect this to what we saw earlier with strconv.Atoi. If parsing succeeds, err will be nil. If it fails, err will be non-nil, and inside it there will be a concrete error object.

package main

import (
	"fmt"
	"strconv"
)

func main() {
	n, err := strconv.Atoi("42")
	fmt.Println(n)          // 42
	fmt.Println(err == nil) // true
}

And now — a provocation:

package main

import (
	"fmt"
	"strconv"
)

func main() {
	n, err := strconv.Atoi("4x")
	fmt.Println(n)   // 0
	fmt.Println(err) // strconv.Atoi: parsing "4x": invalid syntax
}

You can see two facts at once here.

  • First: on error n became 0 — this is the zero value for int.
  • Second: the error contains text that explains what happened.

And this brings us to discipline: if err != nil, the result (n) must not be considered valid, even if it looks “plausible”. Today it might be 0, tomorrow (in other functions) it might be an empty string, a nil slice, and so on.

How fmt prints errors

A very common beginner question: “Why does fmt.Println(err) print normal text, even though err is not a string?”. The correct answer: fmt prints errors by calling the Error() method on the error value.

That is, when you write fmt.Println(err), conceptually it is like “print err.Error()”, except fmt also knows how to handle the case where err == nil.

package main

import (
	"fmt"
	"strconv"
)

func main() {
	_, err := strconv.Atoi("x")

	fmt.Printf("as %%v: %v\n", err) // as %v: strconv.Atoi: parsing "x": invalid syntax
	fmt.Println("as Println:", err) // as Println: strconv.Atoi: parsing "x": invalid syntax
}

Both variants use the same idea: fmt sees a value of type error and understands how to turn it into text.

Another useful trick for diagnostics is printing the type via %T. This is especially helpful when you want to understand which exactly error is inside the error interface:

package main

import (
	"fmt"
	"strconv"
)

func main() {
	_, err := strconv.Atoi("x")
	fmt.Printf("type: %T\n", err) // type: *strconv.NumError
}

The output *strconv.NumError is a hint: “inside error there is a concrete error struct from the strconv package”. It’s useful to remember that error is a wrapper (interface), and inside it can be anything, as long as it has an Error() method.

err.Error() and why we do not analyze the error text

When you see err.Error(), this is not “magic”, but a normal method call via dot: “on the object err, call the method Error”. Strictly by the error contract, this method returns a string.

Sometimes you really do need the string, for example to concatenate it with your own message or show the user only the text. Then err.Error() is appropriate.

package main

import (
	"fmt"
	"strconv"
)

func main() {
	_, err := strconv.Atoi("x")
	if err != nil {
		fmt.Println("message:", err.Error()) // message: strconv.Atoi: parsing "x": invalid syntax
	}
}

But there is an important discipline here: we do not make decisions by analyzing the error text. That is, it’s a bad idea to do this:

// an example of what NOT to do (do not copy)
if err != nil && strings.Contains(err.Error(), "invalid syntax") {
    ...
}

Why is it bad? Because the error text is a message for a human (log/output), not a stable API for a program. Today it’s one thing, tomorrow it’s another.

The correct baseline today is simple: we decide “success/error” by err == nil or err != nil, not by strings.

strconv.Atoi as a factory of real error values

The word “interface” can sound too abstract, so it’s useful to tie it to our most familiar source of errors — strconv.Atoi. Let’s look at two runs: “success” and “error”, and in both cases print both the type and the value of err.

package main

import (
	"fmt"
	"strconv"
)

func main() {
	_, err1 := strconv.Atoi("10")
	fmt.Printf("err1: %v (%T)\n", err1, err1) // err1: <nil> (<nil>)

	_, err2 := strconv.Atoi("1O") // letter O instead of zero
	fmt.Printf("err2: %v (%T)\n", err2, err2) // err2: ... (*strconv.NumError)
}

Notice: for err1 the type prints as <nil>. This is normal: an interface variable of type error does not contain a concrete value, so %T shows <nil>.

For err2 a concrete type *strconv.NumError appears. That is the “concrete error” hidden behind the interface.

And that is why it’s so convenient to live in Go: you don’t need to know in each place that it’s a *strconv.NumError. It’s enough to know: if err != nil, the operation failed, and the error can be printed or returned further.

2. Mini app “Expense tracking”

Now we’ll assemble a small educational example that will evolve further as part of the “errors day”. The idea is as everyday as it gets: we have a list of expenses; each expense is a category and an amount. We want to compute the grand total and totals by category. And we also want the program to not pretend that everything is fine if the user entered “one hundred rubles” instead of 100.

We’ll keep the input format simple: first a number n (how many entries), then n pairs category amount. The amount comes as a string, and we parse it using strconv.Atoi to get a classic error (rather than a “silent conversion to zero”).

First, let’s write a small function to read one token. This will let us see in one place that even fmt.Scan can return an error.

package main

import "fmt"

func readToken() (string, error) {
	var s string
	_, err := fmt.Scan(&s)
	return s, err
}

Now let’s make a function to read an amount: it reads a string token and tries to convert it to an int.

package main

import (
	"strconv"
)

func readAmount() (int, error) {
	token, err := readToken()
	if err != nil {
		return 0, err
	}
	return strconv.Atoi(token)
}

The return style is important here: if an error occurs, we return 0 (the zero value for int) and the error itself. We’re not improving the error text yet — today it’s important for us to understand that this is just a value and it’s either nil or not.

Now let’s assemble main, which reads n, then n entries, and accumulates sums by category in a map[string]int.

package main

import (
	"fmt"
)

func main() {
	n, err := readAmount()
	if err != nil {
		fmt.Println("failed to read n:", err)
		return
	}

	totals := make(map[string]int)

	for i := 0; i < n; i++ {
		cat, err := readToken()
		if err != nil {
			fmt.Println("failed to read category:", err)
			return
		}

		amount, err := readAmount()
		if err != nil {
			fmt.Println("failed to read amount:", err)
			return
		}

		totals[cat] = totals[cat] + amount
	}

	fmt.Println("totals:", totals)
}

This version is intentionally simple: on the very first error we print a message and exit. From the point of view of today’s topic this is ideal, because it shows the main contract: as long as err == nil, we continue; as soon as err != nil, we choose the “error” branch.

To see that the program behaves predictably, imagine this input:

3
food 120
taxi 300
food x

On the third entry strconv.Atoi("x") will return a non-nil error, amount will become 0, and we will not add a “zero expense” as if everything were fine — we will stop and show the user the problem.

Notice one more important thing about map: if the category does not have a sum yet, totals[cat] yields 0 (the zero value for int). That’s why the expression totals[cat] = totals[cat] + amount works for the first entry and for subsequent ones.

The small control-flow diagram looks like this (and it’s a good template for the future):

flowchart TD
    A[Read token/number] --> B{err == nil?}
    B -- yes --> C[Use the value]
    C --> A
    B -- no --> D[Show the error]
    D --> E[Stop]

3. Typical mistakes in the first steps with error

Mistake #1: treating error as a string and checking “empty / non-empty”.
Sometimes you want to do something like if err != "", because “well, an error is text”. But error is a separate type, not a string. The check is correct only via err == nil or err != nil. If you need the text, you get it via err.Error(), but that is a next step, not a way to determine whether an error happened.

Mistake #2: printing the error and continuing computations with an invalid result.
Situation: n, err := strconv.Atoi(...), then you print the error but still use n. The problem is that on error n will almost always be the zero value (for int that is 0), and you will quietly get the wrong total. If you decided the error is critical, then after the message there must be an explicit control-flow change (for example, return from main).

Mistake #3: mixing up “success” and “error” in the condition.
It happens that you write if err == nil { ... handle error ... }. Keep the rule in mind: err != nil is the error branch.

Mistake #4: not understanding that fmt.Println(err) does not simply “print a variable”.
The text appears because fmt calls the Error() method on the error. This is useful to remember so you don’t expect error to “be a string by itself”.

Mistake #5: trying to make decisions based on the text of err.Error().
The temptation is strong: the text is understandable. But it is not intended for branching logic. Get used to the discipline: decide “there is an error or there is no error” via nil/non-nil. The text is for the user/log, not for the algorithm.

1
Task
Go SELF, level 16, lesson 0
Locked
Error sensor
Error sensor
1
Task
Go SELF, level 16, lesson 0
Locked
Custom alert
Custom alert
1
Task
Go SELF, level 16, lesson 0
Locked
Number Gate
Number Gate
1
Task
Go SELF, level 16, lesson 0
Locked
Expense Ledger
Expense Ledger
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION