Files
go-simple-api/lessons/00-go-basics-3-interfaces-errors-concurrency-packages.md
T
2026-07-16 10:13:46 +03:30

13 KiB

Go Basics, Part 3 — Interfaces, Errors, Collections, Packages, and Concurrency

This is the last basics lesson. It covers everything else the main course leans on: interfaces (how http.Handler and similar types work), proper error handling patterns, slices and maps, how packages/modules/imports actually work, a first look at goroutines (needed for graceful shutdown), and JSON encoding/decoding.

1. Interfaces — Go's version of "any type that can do X"

An interface is a type defined purely by a set of method signatures. Any type that has those methods automatically satisfies the interface — there's no implements keyword, no explicit declaration. This is called structural typing or "duck typing, but checked at compile time."

package main

import "fmt"

// Any type with a Speak() string method satisfies Speaker - automatically.
type Speaker interface {
	Speak() string
}

type Dog struct{}
func (d Dog) Speak() string { return "Woof!" }

type Cat struct{}
func (c Cat) Speak() string { return "Meow!" }

func announce(s Speaker) {
	fmt.Println(s.Speak())
}

func main() {
	announce(Dog{}) // Woof!
	announce(Cat{}) // Meow!
}

Dog and Cat never mention Speaker anywhere in their code. They just happen to have a method with the right name and signature, which is enough. This is why, in the main course, *chi.Mux can be passed directly to http.Server{Handler: r}http.Handler is defined (in the standard library) as:

type Handler interface {
	ServeHTTP(ResponseWriter, *Request)
}

*chi.Mux happens to have a ServeHTTP method, so it automatically satisfies http.Handler, with zero extra code. Same story for our own handlers wrapped via http.HandlerFunc(...) — a small built-in adapter type that turns any function shaped func(w, r) into something with a ServeHTTP method, satisfying the interface.

any (a.k.a. interface{})

The empty interface — one with zero required methods — is satisfied by every type, since every type trivially has "at least zero" methods. Go has a built-in alias for this: any (added in Go 1.18; older code uses the equivalent interface{}).

func describe(v any) {
	fmt.Printf("value: %v, type: %T\n", v, v)
}

describe(42)          // value: 42, type: int
describe("hello")     // value: hello, type: string
describe(User{})      // value: {}, type: main.User

You'll see any used for things like generic JSON response helpers (map[string]any) where the value could be a string, a number, a nested object — anything.

Type assertions

If you have a value typed as an interface (or any) and need the concrete type back out, use a type assertion:

var v any = "hello"

s := v.(string) // single-value form - PANICS if v isn't actually a string

s, ok := v.(string) // two-value form - SAFE: ok is false on mismatch, no panic
if !ok {
	fmt.Println("v was not a string")
}

Always prefer the two-value form unless you're absolutely certain of the type — a failed single-value assertion crashes your program. This shows up in the main course when reading a value back out of a context.Context (Lesson 8) — the value is stored as any, so you need a type assertion to get a concrete struct back.

2. Error handling, properly

Go's error is just an interface:

type error interface {
	Error() string
}

Any type with an Error() string method IS an error. The standard library gives you two easy ways to create one:

import (
	"errors"
	"fmt"
)

err1 := errors.New("something went wrong")
err2 := fmt.Errorf("failed to process user %d", 42)

The if err != nil pattern

func readConfig() (string, error) {
	// pretend this can fail
	return "", errors.New("config file not found")
}

func main() {
	config, err := readConfig()
	if err != nil {
		fmt.Println("error:", err)
		return // stop here - don't continue using `config`, it's meaningless
	}
	fmt.Println("config:", config)
}

Checking err != nil after every call that can fail, and handling it immediately, is the single most repeated pattern in idiomatic Go — and in the entire main course.

Wrapping errors with %w

When an error crosses through several layers of your program, it's useful to add context at each layer without losing the original error:

func openFile() error {
	return errors.New("file not found")
}

func loadConfig() error {
	if err := openFile(); err != nil {
		return fmt.Errorf("load config: %w", err) // %w WRAPS, preserving err
	}
	return nil
}

%w (as opposed to %v or %s) specifically wraps the original error, meaning code further up the chain can still inspect what the original error actually was, using errors.Is or errors.As.

Sentinel errors and errors.Is

A sentinel error is a specific, predefined error value that callers can check for by identity, not by comparing message strings (which is fragile — messages change, causes bugs).

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

func findUser(id int) (string, error) {
	if id != 1 {
		return "", ErrNotFound
	}
	return "Hamid", nil
}

func main() {
	_, err := findUser(99)
	if errors.Is(err, ErrNotFound) {
		fmt.Println("no such user!")
	}
}

errors.Is works correctly even if the error was wrapped with %w several layers deep — it "unwraps" automatically to check. This exact pattern (var ErrUserNotFound = errors.New(...), then errors.Is(err, ErrUserNotFound)) is used throughout the main course's repository layer.

3. Slices and maps — Go's core collection types

Slices — dynamically-sized lists

// A slice literal
names := []string{"alice", "bob", "carol"}

fmt.Println(names[0])     // "alice" - zero-indexed
fmt.Println(len(names))   // 3

names = append(names, "dave") // append returns a NEW slice - reassign it!
fmt.Println(names)            // [alice bob carol dave]

// An empty slice, grown later
var scores []int
scores = append(scores, 10)
scores = append(scores, 20)

// Looping (seen in Part 1, repeated here for completeness)
for i, name := range names {
	fmt.Println(i, name)
}

Important: append may or may not modify the original underlying array — you should always use the return value (names = append(names, ...)), never assume the original variable was updated in place.

Maps — key/value lookups

ages := map[string]int{
	"alice": 30,
	"bob":   25,
}

fmt.Println(ages["alice"]) // 30
ages["carol"] = 28          // add/update a key
delete(ages, "bob")         // remove a key

// Reading a key that doesn't exist returns the TYPE'S ZERO VALUE, not an
// error or nil-equivalent crash:
fmt.Println(ages["nobody"]) // 0 (the zero value for int)

// The "comma ok" idiom - check whether a key actually exists:
age, ok := ages["nobody"]
if !ok {
	fmt.Println("no such key")
}

// Looping over a map (order is NOT guaranteed - it's randomized each run)
for name, age := range ages {
	fmt.Println(name, age)
}

You'll see map[string]any used constantly in the main course for building ad-hoc JSON responses, e.g. map[string]any{"id": user.ID, "email": user.Email}.

4. Packages, imports, and modules — how a real project is organized

You already saw package main in Part 1. Any other folder full of .go files declares its own package name (usually matching the folder name), and can be imported by other code.

myproject/
├── go.mod
├── main.go          -- package main
└── greeter/
    └── greeter.go    -- package greeter

greeter/greeter.go

package greeter

func Hello(name string) string {
	return "Hello, " + name + "!"
}

main.go

package main

import (
	"fmt"

	"myproject/greeter" // import path = module path + folder path
)

func main() {
	fmt.Println(greeter.Hello("Hamid"))
}

The import path "myproject/greeter" is built from the module's name (declared in go.mod via module myproject) plus the folder path. This is exactly the pattern behind every internal import you'll see in the main course, e.g.:

import "git.hamidsoltani.com/hamid/go-simple-api/internal/config"

— the module is git.hamidsoltani.com/hamid/go-simple-api (declared once, at the top of the project's go.mod), and internal/config is the folder path to that specific package.

The special internal/ folder

Any package inside a folder literally named internal/ can ONLY be imported by code within the same module (specifically, code rooted at the parent of internal/). This is a compiler-enforced way to say "this code is a private implementation detail of this project, not a public library for others to import." The main course's entire codebase lives under internal/ for exactly this reason.

External packages and go.mod

To use code someone else published (like the chi router), you add it as a dependency:

go get github.com/go-chi/chi/v5@latest

This downloads the package, records it in go.mod (a "require" line with a specific version), and records exact checksums in go.sum (so builds are reproducible and verifiably untampered). After that, you import it just like any other package:

import "github.com/go-chi/chi/v5"

go mod tidy is a command you'll run often — it scans your code for imports it doesn't yet know about, fetches them, and also removes go.mod entries for anything you've stopped importing.

5. A first look at goroutines (needed for Lesson 1's graceful shutdown)

A goroutine is a lightweight, independently-running function — Go's built-in concurrency primitive. You start one with the go keyword:

package main

import (
	"fmt"
	"time"
)

func sayHello() {
	fmt.Println("hello from a goroutine")
}

func main() {
	go sayHello() // starts sayHello running CONCURRENTLY, doesn't block

	fmt.Println("this may print before OR after 'hello from a goroutine'")

	time.Sleep(100 * time.Millisecond) // give the goroutine time to run
	// without this Sleep, main() might exit before sayHello ever runs -
	// when main() returns, the WHOLE PROGRAM exits immediately, goroutines
	// and all.
}

The key thing to understand: go someFunction() starts someFunction running in the background and immediately continues to the next line — it does not wait for someFunction to finish. This is exactly why the main course wraps srv.ListenAndServe() in a goroutine in Lesson 1: that call blocks forever (serving requests) — running it as a goroutine frees up main()'s main line of execution to move on and listen for shutdown signals (Ctrl+C) instead of getting stuck forever inside ListenAndServe.

We won't go deeper into concurrency (channels, sync.WaitGroup, etc.) in this course — the main project only needs this one goroutine pattern.

6. JSON basics with encoding/json

Go's standard library can convert between Go values and JSON text automatically, using struct tags (from Part 2) to control field naming.

Encoding (Go value → JSON)

package main

import (
	"encoding/json"
	"fmt"
)

type User struct {
	Name string `json:"name"`
	Age  int    `json:"age"`
}

func main() {
	u := User{Name: "Hamid", Age: 31}

	// Marshal converts a Go value into a []byte of JSON text
	data, err := json.Marshal(u)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(string(data)) // {"name":"Hamid","age":31}
}

Decoding (JSON → Go value)

jsonText := `{"name":"Sara","age":28}`

var u User
err := json.Unmarshal([]byte(jsonText), &u) // note the & - Unmarshal WRITES into u
if err != nil {
	fmt.Println("error:", err)
	return
}
fmt.Println(u.Name, u.Age) // Sara 28

Note the &u — just like rows.Scan(&x) from database code, Unmarshal needs to write into your variable, so it needs its address.

Streaming versions: Encoder/Decoder

When working with HTTP requests/responses (which are streams, not in-memory byte slices), you'll more often see the streaming forms:

// Writing JSON directly to an io.Writer (e.g. http.ResponseWriter)
json.NewEncoder(w).Encode(u)

// Reading JSON directly from an io.Reader (e.g. an HTTP request body)
var u User
json.NewDecoder(r.Body).Decode(&u)

These do the same job as Marshal/Unmarshal but write/read directly to a stream instead of requiring a full []byte up front. You'll use NewDecoder(r.Body).Decode(...) and NewEncoder(w).Encode(...) on nearly every handler in the main course, starting in Lesson 1.

7. You're ready

That's everything the main course leans on. A quick self-check — if these all feel familiar, you're ready for Lesson 1:

  • Declaring variables with := and var, and Go's zero values
  • Writing functions with multiple return values, and the if err != nil pattern
  • Structs, exported vs. unexported fields, struct tags
  • Pointers: & to get an address, * to dereference, and why functions take *User instead of User when they need to modify it
  • Methods with value vs. pointer receivers
  • Interfaces being satisfied implicitly (no implements keyword)
  • Slices (append, indexing, range) and maps (map[string]any, the comma-ok idiom)
  • How packages/imports/modules fit together, and what internal/ means
  • go someFunc() starting a goroutine, and why that matters for a blocking call like ListenAndServe
  • json.NewEncoder(w).Encode(...) / json.NewDecoder(r.Body).Decode(&x)

Head to lesson-01-project-skeleton-chi-routing.md next.