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
:=andvar, and Go's zero values - Writing functions with multiple return values, and the
if err != nilpattern - Structs, exported vs. unexported fields, struct tags
- Pointers:
&to get an address,*to dereference, and why functions take*Userinstead ofUserwhen they need to modify it - Methods with value vs. pointer receivers
- Interfaces being satisfied implicitly (no
implementskeyword) - 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 likeListenAndServejson.NewEncoder(w).Encode(...)/json.NewDecoder(r.Body).Decode(&x)
Head to lesson-01-project-skeleton-chi-routing.md next.