Go Idioms as Patterns
Go has no classes, no inheritance and no exceptions — so its design patterns look different. Implicit interfaces, errors as values, embedding, useful zero values and small packages: what they are, how they work under the hood, and how they compare with JavaScript.
Same story, different language
If you come from JavaScript, your first Go code will probably work but look odd to a Go reviewer. You'll write a big interface next to every struct, try to fake inheritance, or wonder where try/catch went. That code isn't wrong — it's just not idiomatic.
What's missing, and what replaces it
The design patterns you may have read about (factory, strategy, decorator…) were mostly described for languages with classes. Go gets the same results with a smaller toolbox. Here's the whole page on one table:
| In JavaScript you'd use… | In Go you use… | Section |
|---|---|---|
class Admin extends User (Inheritance) | a Struct that embeds another struct | §08 |
implements (TypeScript) or "duck typing" | an Interface the type satisfies automatically | §02, §03 |
throw / try / catch | return err / if err != nil | §05, §06 |
| an uncaught crash | panic — only for bugs | §07 |
constructor() with defaults | a zero value that already works | §09 |
if (x === undefined) | the v, ok := form | §09 |
a utils.js file | small, well-named packages | §10 |
Every row on the right is simpler than the one on the left, and that's on purpose. Go was designed for big teams reading each other's code. So it prefers one obvious way that is a bit more verbose over clever tricks that hide what's happening.
Five sentences to remember
These are adapted from the Go Proverbs, a short talk by Rob Pike, one of Go's designers. The rest of the page explains each one.
The bigger the interface, the weaker the abstraction
io.Reader, fmt.Stringer) are the most useful ones. §02Accept interfaces, return structs
Errors are values
Don't panic
panic is for "this can't happen" bugs, not for "file not found". §07Make the zero value useful
var b bytes.Buffer is ready to use. No constructor needed. §09Composition over inheritance
Interfaces you never declare you implement
implements keyword — the compiler checks the match wherever you use it.An Interface is a list of method signatures. Here's one of the most-used interfaces in Go, from the standard library's fmt package:
type Stringer interface {
String() string
}Any type with a String() string method is a Stringer. You don't register it anywhere. fmt.Println checks for it and, if present, uses it to print your value:
type Temp float64 // a new type based on float64
func (t Temp) String() string { // ← this method is all it takes
return fmt.Sprintf("%.1f°C", float64(t))
}
func main() {
fmt.Println(Temp(21.5)) // 21.5°C — Println found String()
}The same idea in JavaScript
JavaScript has no interfaces at all. It uses Duck typing: call the method and hope it's there. TypeScript adds interfaces that are also matched by shape. Go is the same idea, checked at compile time:
type Stringer interface{ String() string }
type User struct{ Name string }
func (u User) String() string { return "user " + u.Name }
func show(s Stringer) { fmt.Println(s.String()) }
show(User{Name: "ann"}) // ✅ compiles: User has String()
show(42) // ❌ compile error: int does not implement StringerThe Go version gives you the flexibility of duck typing — any type can join, even one written years before the interface existed — plus the safety of a compiler check. This combination is called an Implicit interface, and it's what makes the next section's idiom possible.
Small interfaces are the strong ones
The most powerful interfaces in Go have one method. The two below are used by files, network connections, HTTP bodies, gzip, hashing, buffers and hundreds of other types:
type Reader interface {
Read(p []byte) (n int, err error) // fill p with up to len(p) bytes
}
type Writer interface {
Write(p []byte) (n int, err error) // write the bytes in p somewhere
}Because io.Copy(dst Writer, src Reader) asks for only these two tiny behaviours, it can copy a file to a network connection, an HTTP body into a gzip writer, or a string into a hash — combinations its authors never imagined. The fewer methods an interface demands, the more types can satisfy it, and the more places it's useful.
Bigger interfaces are built by embedding small ones
When you need both, you combine them. Listing one interface inside another is called interface embedding: the result requires every method of both.
type ReadWriter interface {
Reader // all of Reader's methods…
Writer // …plus all of Writer's
}Who qualifies? Method sets and pointer receivers
One rule trips up almost everyone once. A method can have a value receiver, (f File), or a pointer receiver, (f *File), when it needs to change the original. The list of methods a type "has", as far as interfaces care, is its Method set:
- a
*File(pointer) has all of File's methods; - a plain
Filevalue has only the value-receiver ones.
Why? If you store a plain File value in an interface, Go holds a copy of it. A pointer method like Write is meant to change the original — and there's no original address to give it. So Go refuses, instead of silently changing a copy. Try it:
type Temp float64
func (t Temp) String() string { … } // value receiver
type File struct{ name string }
func (f File) String() string { … } // value receiver
func (f *File) Read(p []byte) (int, error) // pointer receiver
func (f *File) Write(p []byte) (int, error) // pointer receiverFile does not implement io.Writer (method Write has pointer receiver). Fix: pass & — a pointer, *File.Accept interfaces, return structs
A worked example
A billing package needs to load and save invoices. The database code lives in another package, postgres, whose Store has dozens of methods. Here's the non-idiomatic version, then the idiomatic one:
package billing
import "example.com/app/postgres"
type Service struct {
store *postgres.Store // needs a real DB
}
func New(s *postgres.Store) *Service {
return &Service{store: s}
}
// To test Service you now need Postgres
// running, or a fake of ALL Store methods.package billing
// Only the two methods billing calls.
type invoiceStore interface {
GetInvoice(ctx context.Context, id string) (Invoice, error)
SaveInvoice(ctx context.Context, inv Invoice) error
}
type Service struct{ store invoiceStore }
func New(s invoiceStore) *Service { // accept an interface
return &Service{store: s} // return a struct
}postgres package exports a concrete struct with many methods and knows nothing about who uses it. The billing package declares the two methods it actually needs. *postgres.Store satisfies that interface automatically, and so does a tiny fake in the tests.Three things happened in the right-hand version:
- The interface lives with the code that uses it (the consumer), not with the code that implements it.
postgresdoesn't even knowbillingexists — implicit interfaces (§02) make that possible. - It lists two methods, not forty. Anything with those two methods works: Postgres, an in-memory map, a cache wrapper, a test fake.
Newreturns*Service, a concrete type. Callers get every method and field, can see real docs in their editor, and you can add methods later without breaking anyone.
Try plugging different types into New and see what the compiler says:
✅ Compiles. It has many more methods, but billing only asks for two — the extras don't matter. postgres never imported billing.
Why this makes tests easy
The test can now pass in a tiny fake instead of a database. A fake is one kind of Test double: a stand-in object that behaves just enough like the real thing.
Here's the Invoice type and the method the test will exercise:
type Invoice struct {
ID string
Total int // in cents
}
// ApplyDiscount loads an invoice, takes `pct` percent off, and saves it.
func (s *Service) ApplyDiscount(ctx context.Context, id string, pct int) error {
inv, err := s.store.GetInvoice(ctx, id)
if err != nil {
return fmt.Errorf("apply discount %s: %w", id, err)
}
inv.Total = inv.Total * (100 - pct) / 100
return s.store.SaveInvoice(ctx, inv)
}// billing_test.go
type fakeStore struct{ saved []Invoice }
func (f *fakeStore) GetInvoice(ctx context.Context, id string) (Invoice, error) {
return Invoice{ID: id, Total: 100}, nil
}
func (f *fakeStore) SaveInvoice(ctx context.Context, inv Invoice) error {
f.saved = append(f.saved, inv) // remember what was saved
return nil
}
func TestApplyDiscount(t *testing.T) {
fake := &fakeStore{}
svc := New(fake) // ✅ *fakeStore satisfies invoiceStore
_ = svc.ApplyDiscount(context.Background(), "inv-1", 10)
if got := fake.saved[0].Total; got != 90 {
t.Fatalf("total = %d, want 90", got)
}
}This is Dependency injection in its simplest form: the dependency is passed in instead of created inside. Wiring big programs this way has its own topic — see Dependency Injection.
A shortcut for big interfaces you don't own
Sometimes you must accept a big interface from someone else's package — imagine a third-party storage.Bucket interface with 12 methods — and your test only cares about one. A struct field can have an interface type, and you can embed an interface in a struct too (embedding is explained fully in §08; it makes the field's methods callable on the struct). An embedded interface field stays nil until you set it. So embed it in your fake and override just the one method:
type fakeBucket struct {
storage.Bucket // ← embedded interface: "the other 11 methods"
objects map[string][]byte
}
// Override only what the test uses.
func (f *fakeBucket) Get(ctx context.Context, key string) ([]byte, error) {
return f.objects[key], nil
}fakeBucket now satisfies all of storage.Bucket. The embedded field is left as nil, so calling any method you didn't override panics with a nil-pointer error — which in a test is exactly what you want: a loud failure that says "this test touched something unexpected". (How embedding works in general is §08.)
Inside an interface: the nil that isn't nil
nil only when both slots are empty.That one fact explains Go's most famous gotcha, and it's also how type assertions work. It matters most for error, which is itself an interface (§05).
The two slots
When you write var r io.Reader = file, Go stores two things in r:
- the type slot: the concrete type now inside, here
*os.File. This is called the Dynamic type, because it can change each time you assign. - the value slot: the actual data — here, the pointer to the file.
Calling r.Read(buf) looks at the type slot to find which Read method to run, then calls it with the value. This pair is an Interface value. Now watch what happens when the value is a nil pointer:
var err error // declared, nothing assigned
fmt.Println(err == nil) // ?err == nil means. Prints true.The trap in plain words: a nil pointer is not a nil interface. A *NotFound that is nil still has a type, and putting it into an error writes that type into the type slot. err == nil asks "are both slots empty?", and the answer is no.
Asking what's in the box: type assertions
Because the box remembers the type, you can ask for it back. A Type assertion says "I think the interface holds a T; give me the T":
var x any = "hello" // any = an interface with no methods: holds anything
s := x.(string) // ✅ s is "hello"
fmt.Println(s)
// n := x.(int) // 💥 would panic: interface conversion: interface {} is string, not int
n, ok := x.(int) // ✅ no panic: n is 0, ok is false
if ok {
fmt.Println("an int:", n)
}Many possible types: the type switch
When there are several types to handle, a Type switch checks the type slot once and branches:
func describe(x any) string {
switch v := x.(type) { // ← .(type) only works inside a switch
case nil:
return "nothing" // both slots empty
case string:
return "text: " + v // here v is a string
case int, int64:
return fmt.Sprint("number: ", v) // several types: v stays `any`
case fmt.Stringer:
return "stringer: " + v.String() // interfaces work as cases too
default:
return fmt.Sprintf("other: %T", v) // %T prints the type name
}
}Cases are checked top to bottom, so put specific types before broad interfaces. Type switches are handy at edges — decoding JSON, handling several message types. If you find one in the middle of your business logic, a method on an interface is usually cleaner: each type then carries its own behaviour, and adding a new type doesn't mean editing every switch.
Errors are values
The shape of every Go call that can fail
func loadConfig(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil { // ← the most-written line in Go
return Config{}, err // hand the problem to our caller
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, err
}
return cfg, nil // nil error = success
}Why Go chose this
The diagram shows the difference. An exception jumps over every function in between; a returned error walks back up through each one:
dbGet straight to the nearest catch, skipping the layers in between — you can't see the jump by reading them. Bottom: a Go error is returned by each layer in turn, and each one can add context (or decide to handle it) on the way.This buys three things:
- You can see every place that can fail. Each
if err != nilis visible in the code. In JS, any line insidetrymight throw, and you can't tell which by reading it. - Each layer can add context on the way up ("load profile: user 7: not found") — that's §06.
- Errors are just data, so you can store them, put them in a slice, send them on a channel, or compare them, like any value.
The price is verbosity: if err != nil { return …, err } appears constantly. Go's designers decided that's worth it.
What is an error?
error is a built-in interface with a single method (§02 said small interfaces are strong — here's the proof):
type error interface {
Error() string
}Anything with an Error() string method is an error. There are three common ways to make one, from simplest to richest:
// 1. A one-off message.
err := errors.New("disk full")
err = fmt.Errorf("user %d has no email", id) // Errorf = formatted message
// 2. A sentinel: one exported, pre-made value that callers can check for.
var ErrNotFound = errors.New("not found") // by convention: named ErrXxx
// 3. A custom type: carries structured data, not just text.
type ValidationError struct {
Field string
Issue string
}
func (e *ValidationError) Error() string {
return e.Field + ": " + e.Issue
}A Sentinel error is like a well-known code: io.EOF ("no more input"), sql.ErrNoRows, fs.ErrNotExist. The caller checks "is it that one?". A Custom error type is for when the caller needs details — which field was invalid, which HTTP status, which file path. §06 shows how to check for both, even after they've been wrapped.
Wrapping, errors.Is and errors.As
errors.Is and errors.As look through the wrapping to find it.A bare not found reaching your HTTP handler is useless: not found where? Each layer knows a little more about what was going on, so each adds a few words. That's Error wrapping, and Go does it with one extra letter in fmt.Errorf: %w.
Watch a chain form and get unwrapped
Step through it. Notice that no layer knows what the others wrote — each just adds its own context and passes the error up. Then flip the toggle to %v and replay: the printed message is identical, but the chain is broken:
1var ErrNotFound = errors.New("not found")23func dbGet(id int) error {4 return ErrNotFound5}6func repoGet(id int) error {7 if err := dbGet(id); err != nil {8 return fmt.Errorf("user %d: %w", id, err)9 }10 return nil11}12func loadProfile(id int) error {13 if err := repoGet(id); err != nil {14 return fmt.Errorf("load profile: %w", err)15 }16 return nil17}1819err := loadProfile(7) // in the HTTP handler20fmt.Println(err)21if errors.Is(err, ErrNotFound) { /* 404 */ }
error as an ordinary return value — nothing is thrown.The result is an Error chain. Two functions walk it from the outside in:
| Question | Function | Typical use |
|---|---|---|
| "Is this specific error anywhere in the chain?" | errors.Is(err, ErrNotFound) | sentinels: io.EOF, sql.ErrNoRows, context.Canceled |
| "Is there an error of this type in the chain? Give it to me." | errors.As(err, &target) | custom types carrying data: *fs.PathError, *ValidationError |
errors.As: pulling out a custom type
errors.As is the error version of a type assertion (§04) that also looks through wrapping. You give it a pointer to a variable; if it finds a matching error, it fills that variable in:
_, err := os.Open("/etc/app.yaml") // assume the file is missing
if err != nil {
err = fmt.Errorf("load config: %w", err) // wrapped once
}
var pathErr *fs.PathError // the type we're looking for
if errors.As(err, &pathErr) { // ← note the &
fmt.Println("failed path:", pathErr.Path) // /etc/app.yaml
fmt.Println("operation:", pathErr.Op) // open
}
if errors.Is(err, fs.ErrNotExist) { // also true — see below
fmt.Println("file is missing")
}One subtlety: the error inside the PathError is really the operating system's code ENOENT ("no such file"), not fs.ErrNotExist itself. errors.Is still says true, because that OS error type has an Is method declaring "I count as fs.ErrNotExist" (the staff note at the end of this section shows how).
The same thing in JavaScript: Error.cause
Since ES2022 (Node 16.9+), JS errors can carry a cause, which is wrapping by another name. What JS lacks is a built-in errors.Is: you walk the chain yourself.
var ErrNotFound = errors.New("not found")
func repoGet(id int) (User, error) {
u, err := db.Get(id)
if err != nil {
return User{}, fmt.Errorf("repo: user %d: %w", id, err)
}
return u, nil
}
_, err := repoGet(7)
if errors.Is(err, ErrNotFound) { // walks the whole chain
// 404
}Many errors at once: errors.Join
Sometimes several things fail together — validating five form fields, or closing three files. Go 1.20 added errors.Join, which bundles errors into one. errors.Is and errors.As search every branch:
var errs []error
if u.Name == "" {
errs = append(errs, &ValidationError{Field: "name", Issue: "required"})
}
if !strings.Contains(u.Email, "@") {
errs = append(errs, &ValidationError{Field: "email", Issue: "invalid"})
}
err := errors.Join(errs...) // nil if errs is empty — handy!
// err.Error() is the messages joined by newlines:
// name: required
// email: invalidfmt.Errorf can also wrap more than one error since Go 1.20: fmt.Errorf("sync failed: %w; cleanup failed: %w", err1, err2). JavaScript's nearest equivalent is AggregateError, whose .errors array holds every failure.
panic and recover: for bugs, not for flow
error when something could reasonably go wrong (file missing, bad input). panic only when the program itself is broken — a bug that no caller could handle.Go does have something exception-like: panic / recover. A panic stops the current function, runs its deferred calls, then does the same in its caller, and so on up the Call stack — that's called unwinding the stack. If nothing stops it, the whole program crashes and prints a Stack trace.
When panicking is right
| Situation | Panic? | Why |
|---|---|---|
| User sent invalid JSON | ❌ return an error | expected; the caller can reply 400 |
| Database is down | ❌ return an error | expected in production; retry or fail the request |
| Index out of range, nil map write | ✅ Go panics for you | a bug — the code is wrong |
A "can't happen" default: in a switch | ✅ panic("unreachable: …") | if it happens, the code is wrong |
| Startup config that must be valid (a hard-coded regex) | ✅ via a Must… helper | failing at start is better than running broken |
The Must pattern from the last row is common in the standard library: a function that returns an error gets a twin that panics instead, meant for values known when you write the code.
// At package level, runs once at program start.
// If the pattern is invalid, crash immediately — it's a typo in our code.
var slugRe = regexp.MustCompile(`^[a-z0-9-]+$`)
// For input from users, use the version that returns an error:
re, err := regexp.Compile(userPattern)
if err != nil {
return fmt.Errorf("bad pattern: %w", err)
}recover: catching a panic
recover() stops a panic in progress. It only works inside a deferred function, and only for a panic in the same goroutine:
func safeHandle(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil { // v is whatever was passed to panic
log.Printf("panic: %v\n%s", v, debug.Stack())
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
handle(w, r) // if this panics, the defer runs
}Embedding: composition with shortcuts
Inheritance says "an Admin is a User". Go doesn't have it. Instead it encourages Composition: "a Service has a Logger". Embedding is just composition with less typing.
From a normal field to an embedded one
type Service struct {
log Logger // named field
}
s.log.Log("started") // always go through .logtype Service struct {
Logger // no field name = embedded
}
s.Log("started") // shortcut for s.Logger.Log
s.Logger.Log("started") // the long form still worksThat's Struct embedding. The embedded field's name is its type name (Logger), and its methods are promoted: callable on the outer struct as if they were its own. That's Method promotion. Promoted methods also count toward the outer type's method set (§02), so if Logger satisfies some interface, Service does too. (Same pointer rule as §02: pointer-receiver methods of an embedded Logger are promoted only to *Service, not to a plain Service value.)
Service value simply contains a Base and a Logger, like any other fields. Embedding adds shortcuts (dashed): s.Log means s.Logger.Log. There is no parent/child link going the other way.The surprise: no overriding from the inside
Here's where embedding and inheritance part ways. If the inner type's method calls another method, it calls its own version — even when the outer type has a method with the same name. Click s.Hello(), then switch to JS extends to compare:
type Base struct{}
func (b Base) Name() string { return "base" }
func (b Base) Hello() string { return "hello from " + b.Name() }
func (b Base) Close() error { return nil }
type Logger struct{ prefix string }
func (l Logger) Log(msg string) { fmt.Println(l.prefix + msg) }
func (l Logger) Close() error { return nil }
type Service struct {
Base // embedded: no field name
Logger // embedded
}
func (s Service) Name() string { return "service" }
s := Service{Logger: Logger{prefix: "[svc] "}}- 1Depth 0: Service has no
Hello. Depth 1: Base.Hello → promoted. - 2Go rewrites it to
s.Base.Hello(). The receiverbis the inner Base value — it has no idea it lives inside a Service. - 3Inside,
b.Name()looks upNameon Base, not on Service → Base.Name.
Why "hello from base"? Go rewrites s.Hello() as s.Base.Hello(). Inside Hello, the receiver b is the Base value sitting inside s — just a Base, with no link back to the Service around it. So b.Name() can only mean Base.Name.
In a JS class, this is the whole object, so a parent method calling this.name() reaches the child's override. That's called Virtual dispatch, and it's what makes "override one step of the parent's algorithm" designs work. Go structs don't have it.
Getting "override" behaviour the Go way
If you need the inner code to call something customizable, make that part an interface (or a function) field and pass it in. The behaviour is then chosen explicitly, instead of by a hidden parent-child link:
type Namer interface{ Name() string }
type Greeter struct {
namer Namer // the customizable part, passed in
}
func (g Greeter) Hello() string {
return "hello from " + g.namer.Name() // calls whatever was given
}
type Service struct{}
func (Service) Name() string { return "service" }
g := Greeter{namer: Service{}}
fmt.Println(g.Hello()) // hello from service ✓This is the strategy pattern in a nutshell — Behavioral patterns covers it properly. Wrapping a value to add behaviour around it (decorators, HTTP middleware) is in Structural patterns.
Embedding exports everything — including what you didn't mean to
Promotion is all-or-nothing: every exported method of the embedded type becomes part of the outer type's public API. The classic accident is embedding a mutex:
type Cache struct {
sync.Mutex // promotes Lock() and Unlock()
items map[string]string
}
// Any caller can now do this:
c.Lock() // …and forget to Unlock → every Get hangstype Cache struct {
mu sync.Mutex // private: only Cache's methods use it
items map[string]string
}
func (c *Cache) Get(k string) string {
c.mu.Lock()
defer c.mu.Unlock()
return c.items[k]
}Callers can now lock your cache from outside, and you can never remove that ability without breaking them. Keep the mutex as a named, lowercase field. (Mutexes themselves are covered in Locks.) The same reasoning applies to embedding anything with a big API, like *sql.DB or http.Client: you're publishing all of it.
Everyday idioms: zero values, comma-ok, defer, generics
ok value instead of magic "not found" values, defer right after you open something, and generics only for containers and algorithms.Make the zero value useful
Every Go variable starts with a Zero value: 0, "", false, nil, or a struct with all fields at their zero values. Well-designed types are ready to use in that state, so callers don't need a constructor:
var buf bytes.Buffer // no New… call needed
buf.WriteString("hello") // works immediately
var mu sync.Mutex // an unlocked mutex
var wg sync.WaitGroup // a counter at 0
type Counter struct {
hits map[string]int // ⚠️ zero value is a nil map
}
func (c *Counter) Add(k string) {
if c.hits == nil { // ← make the zero value work
c.hits = make(map[string]int)
}
c.hits[k]++
}
var c Counter
c.Add("home") // ✅ no constructor requiredConstructors only when you need them
Go has no constructor keyword. When a type can't have a useful zero value — it needs a required dependency, or must validate input — write a plain function named NewX (a Constructor):
func NewServer(addr string, store invoiceStore) (*Server, error) {
if addr == "" {
return nil, errors.New("server: addr is required")
}
return &Server{addr: addr, store: store, timeout: 30 * time.Second}, nil
}Return an error if construction can fail. If a constructor grows many optional settings, the idiomatic answer is functional options — covered in Creational patterns.
The comma-ok idiom
Go doesn't use special "not found" values like undefined or -1. When an operation might come up empty, it can return a second boolean — the Comma-ok idiom form you met with type assertions in §04. Here are all three places it appears:
ages := map[string]int{"ann": 0}
age, ok := ages["bob"] // map lookup
if !ok {
fmt.Println("no bob") // without ok, you'd get 0 and couldn't tell
}
s, ok := value.(fmt.Stringer) // type assertion (§04): no panic if wrong
msg, ok := <-ch // channel receive: ok is false once ch is closedThe map case is the one that bites: ages["ann"] and ages["bob"] both return 0, but only one of them is in the map. (For channels, see Channels §02.)
defer: clean up right where you open
Put the cleanup on the line right after acquiring the resource. defer runs it when the function returns, however it returns — normal return, early return err, or panic:
func countLines(path string) (int, error) {
f, err := os.Open(path)
if err != nil {
return 0, err
}
defer f.Close() // ← paired with Open, can't be forgotten
n := 0
sc := bufio.NewScanner(f)
for sc.Scan() {
n++
}
return n, sc.Err() // f.Close() runs after this
}Deferred calls run in reverse order (last deferred, first run), like stacking plates. Avoid defer inside a long loop: nothing runs until the function returns, so 10,000 opened files would stay open until the end.
Generics or interfaces?
Since Go 1.18, functions and types can take type parameters, written in square brackets. Each one has a Type constraint saying which types are allowed:
// Works for a slice of ANY element type, keeps the type in the result.
func Filter[T any](xs []T, keep func(T) bool) []T {
var out []T
for _, x := range xs {
if keep(x) {
out = append(out, x)
}
}
return out
}
evens := Filter([]int{1, 2, 3, 4}, func(n int) bool { return n%2 == 0 }) // []int{2, 4}
// A constraint listing allowed types:
type Number interface{ ~int | ~int64 | ~float64 }
func Sum[T Number](xs []T) T {
var total T // zero value of T
for _, x := range xs {
total += x // + is allowed: every type in Number supports it
}
return total
}The rule of thumb:
| Use… | When | Example |
|---|---|---|
| an interface | different types behave differently behind the same methods | io.Reader: a file reads from disk, a socket from the network |
| generics | the code is identical for every type; only the element type changes | slices.Sort, maps.Keys, a typed cache or set |
| a type switch | a fixed, small set of types at an edge (§04) | decoding JSON values |
If you write a generic function whose body only calls methods on T, it probably just wants an interface parameter. And if you're unsure, start without generics — the standard library's slices and maps packages already cover most needs.
Package design and naming
http.Server, json.Marshal, time.Now.A Package / module in Go is one folder of .go files. Code outside the package uses it as pkgname.Thing, and can only see names that start with a capital letter. Those two facts drive most Go naming rules.
Don't stutter
Because callers always write the package name, repeating it in the type name says everything twice:
package http
type HTTPServer struct{ … }
type HTTPRequest struct{ … }
// caller: http.HTTPServer, http.HTTPRequestpackage http
type Server struct{ … }
type Request struct{ … }
// caller: http.Server, http.Request
// (what the standard library does)Same for functions: when a package has one main type, its constructor is just New — list.New(), template.New(name) — so you'd write user.New(), not user.NewUser(). And it's bytes.Buffer, not bytes.ByteBuffer.
Name packages for what they provide
| Avoid | Prefer | Why |
|---|---|---|
util, common, helpers, misc | slug, money, retry | A name that describes nothing attracts everything: it grows into a pile every package imports |
models holding every type | types live next to the code that uses them | splitting by "kind of code" instead of by feature creates Coupling between everything |
userService, user_service | user | package names are short, lowercase, one word, no underscores |
types.go, interfaces.go | invoice.go, store.go | group files by feature too |
When a function in util is used by one package, move it there. If several packages need it, it deserves a real package with a real name — strings, not stringutil.
internal/: code that can't leak
Any package under a folder named internal can only be imported by code inside the parent of that folder. The compiler enforces it:
myapp/
├── cmd/server/main.go # can import myapp/internal/...
├── billing/billing.go # can import myapp/internal/...
└── internal/
└── pgstore/store.go # other modules can't import thisHere a module means a whole Go project with its own go.mod file, roughly an npm package. An internal/ package lets you split code into packages for your own clarity without promising anything to the outside world. Anything exported outside internal/ becomes your public API, and other modules may start depending on it.
Short names in small scopes
Go prefers short names when the meaning is obvious from nearby code:
- Receivers: one or two letters from the type name, and the same everywhere —
func (s *Server) Start(), neverthisorself. - Loop and local variables:
i,r(request),w(writer),ctx,err. - Exported names used far away get full words:
ReadTimeout,ErrNotFound. - Acronyms keep one case:
ServeHTTP,userID,parseURL— notServeHttp,userId. - No
Getprefix on getters:u.Name(), andu.SetName(…)for the setter.
Staff-level: idioms at scale
1 · Interface pollution
Interface pollution is the habit of declaring an interface for every struct "for testability" or "for flexibility". In Go it costs more than it buys:
- Navigation breaks. "Go to definition" lands on an interface, not code. Every reader pays an extra hop, forever.
- The interface fossilizes. Once five packages use a 20-method
UserServiceinterface, splitting it is a big refactor — and every fake must implement all 20. - It solves nothing. Thanks to implicit interfaces (§02), a consumer can declare the 2-method interface it needs later, when a second implementation or a test actually appears.
Don't design with interfaces, discover them.— Rob Pike
The healthy pattern is the one from §03: concrete types in the producer, small interfaces in each consumer, discovered when a real need shows up.
2 · Returning interfaces (and when it's right)
Returning an interface from a constructor hides the concrete type. That has three costs: callers can't reach extra methods or fields without a type assertion; adding a method to that interface later breaks anyone who implemented it; and §04's nil-pointer-in-interface trap becomes possible on every return path.
It is right when the concrete type is truly an implementation detail and several implementations exist behind one constructor — errors.New returns error, sha256.New returns hash.Hash, io.MultiReader returns io.Reader. Notice these are all tiny, stable interfaces from the standard library.
3 · Errors at boundaries: context once, log once, translate at the edge
Three rules keep error handling sane in large codebases:
Add context, not duplication. Each layer adds what it was doing and the key identifiers: "charge invoice %s: %w". Don't repeat the inner message, and don't start with "failed to" or "error:" — in a chain of five, those words become noise.
Log once, at the top. The layer that finally handles the error — the HTTP handler, the job runner, main — logs it, with the full wrapped message. Layers that return it don't log. Otherwise one failure shows up as five log lines that look like five failures.
Translate at package boundaries. Wrapping with %w makes the inner error part of your API: callers can now errors.Is(err, sql.ErrNoRows) against your package, and you can never switch away from SQL without breaking them. At the boundary, convert to your own vocabulary:
var ErrNotFound = errors.New("user: not found") // our sentinel, our promise
func (s *Store) Get(ctx context.Context, id string) (*User, error) {
u, err := s.queryUser(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound // translate: hide the DB detail
}
if err != nil {
return nil, fmt.Errorf("user: get %s: %v", id, err) // %v: message kept, type hidden
}
return u, nil
}Keep stack traces where you need them. Go errors carry no Stack trace by default. A good wrapped message usually tells you more than a trace would. If you need traces (say, for an error tracker), add them in one place — a custom error type created at the boundary — rather than at every layer.
4 · Errors in concurrent code
A goroutine can't return an error to whoever started it; errors must be sent back (over a channel), or collected with errgroup, which returns the first error and — when created with errgroup.WithContext and the goroutines respect ctx — cancels the others. Both are taught in Async / Await §11 and §12. Two idiom-level notes on top:
- When you want every failure, not just the first, collect them in a slice and return
errors.Join(errs...)(§06). Guard the slice with a mutex, or have each goroutine write to its own index. - Treat
context.Canceledandcontext.DeadlineExceeded(check witherrors.Is) as "we stopped on purpose", not as failures to alert on.
5 · API stability: what you can change later
Once other teams import your package, some changes are free and some are a Breaking change:
| Change | Safe? | Why |
|---|---|---|
| Add a method to an exported struct | ✅ | existing code keeps compiling |
| Add a field to a struct | ✅ (mostly) | breaks only unkeyed literals like Point{1, 2} — so always write Point{X: 1, Y: 2} |
| Add a method to an exported interface | ❌ | every type that implemented it stops satisfying it |
| Add a parameter to a function | ❌ | every call breaks — use an options struct or functional options (Creational) |
| Start returning a new sentinel error | ⚠️ | compiles, but callers' errors.Is checks may now behave differently |
Change an error from %v to %w (or back) | ⚠️ | changes what callers' errors.Is can find |
This is one more reason for "return structs, accept small interfaces": structs can grow safely, while exported interfaces are frozen the day you publish them. Go's own standard library honours this with the Go 1 compatibility promise — almost all code written for Go 1.0 in 2012 still compiles today.
Pitfalls
Returning a typed nil as an error
A function returns a nil *MyError through an error result, and err != nil is true on success. The type slot is filled (§04).
Fix: declare the result as error and return nil literally on success.
Ignoring an error with _
data, _ := os.ReadFile(path) compiles happily. When the file is missing, data is empty and the bug surfaces far away.
Fix: handle every error, or leave a comment saying why it's safe to ignore. errcheck (in golangci-lint) flags the rest.
Shadowing err with :=
var err error
if cond {
result, err := compute() // ← NEW err, only inside this block
if err != nil {
log.Println("compute:", err)
}
use(result)
}
return err // always nil: the outer err never changed:= declares new variables whenever at least one name on the left is new in this scope. Inside the block, both result and err are new, so the inner err shadows the outer one, which is never touched. The compiler only complains when the inner err goes unused — so the bug hides as soon as you check it. Step through it:
1func run(cond bool) error {2 var err error3 if cond {4 result, err := compute() // := → a NEW err5 if err != nil {6 log.Println("compute:", err)7 }8 use(result)9 }10 return err11}
err in its own scope (the outer box). It starts as nil.Fix: declare result beforehand and assign with =, or return inside the block.
Wrapping with %v when you meant %w
The message looks identical, but errors.Is and errors.As can no longer see the cause (§06).
Fix: use %w inside your app; choose %v only deliberately at a library boundary (§11).
Comparing errors with == or by their text
err == ErrNotFound breaks as soon as anyone wraps the error; strings.Contains(err.Error(), "not found") breaks when someone rewords it.
Fix: errors.Is for sentinels, errors.As for types.
Expecting embedding to override like extends
A promoted method that calls another method calls the embedded type's version, not the outer type's (§08).
Fix: pass the customizable part in as an interface or function field.
Embedding sync.Mutex, or copying a struct that holds one
Embedding makes Lock/Unlock part of your public API. Separately, passing a struct with a mutex by value copies the lock — the copy and the original no longer protect the same data. go vet reports this as passes lock by value.
Fix: mu sync.Mutex as a named, unexported field, pointer receivers on the methods, and pass the struct as a pointer.
An interface for every struct
type UserService interface next to type userService struct, with the same 15 methods, in the same package. It adds a hop for every reader and solves no real problem (§11).
Fix: export the struct. Let consumers declare small interfaces when they need them.
Using panic for ordinary failures
panic(err) because a file is missing turns a recoverable problem into a crash, and callers can't see from the signature that it can happen.
Fix: return the error. Reserve panic for bugs and Must… helpers on values fixed in the code (§07).
Check yourself: predict the output
Commit to an answer before you click. If you miss one, go back to the widget in that section — the interface box (§04), the error chain (§06), or the method explorer (§08) — and replay it with the puzzle in mind.
type MyErr struct{}
func (*MyErr) Error() string { return "boom" }
func check() error {
var e *MyErr
return e
}
func main() {
err := check()
fmt.Println(err == nil)
}base := errors.New("timeout")
a := fmt.Errorf("fetch: %w", base)
b := fmt.Errorf("fetch: %v", base)
fmt.Println(errors.Is(a, base), errors.Is(b, base))type Animal struct{}
func (Animal) Name() string { return "animal" }
func (a Animal) Intro() string { return "I am " + a.Name() }
type Dog struct{ Animal }
func (Dog) Name() string { return "dog" }
fmt.Println(Dog{}.Name(), "/", Dog{}.Intro())type Counter struct{ n int }
func (c *Counter) Inc() { c.n++ }
type Incrementer interface{ Inc() }
var x Incrementer = Counter{}ages := map[string]int{"ann": 0}
a, okA := ages["ann"]
b, okB := ages["bob"]
fmt.Println(a, okA, b, okB)_, err := os.Open("/missing.txt")
err = fmt.Errorf("load config: %w", err)
var pe *fs.PathError
fmt.Println(errors.Is(err, fs.ErrNotExist), errors.As(err, &pe))Cheat sheet
io.Reader, fmt.Stringer) are the strongest.nil, never a typed nil pointer.if err != nil { return …, fmt.Errorf("doing X: %w", err) }. Handle it or return it — not both.errors.Is(err, ErrNotFound), errors.As(err, &pathErr). Never == on wrapped errors, never match on text.Must… for values fixed in code. Recover only at the edge of a goroutine.sync.Mutex in exported types.var buf bytes.Buffer just works. Write NewX only when a zero value can't be valid.| If you need… | JavaScript | Go |
|---|---|---|
| A contract between types | duck typing / TS implements | interface, satisfied implicitly |
| Report a failure | throw new Error(…) | return …, err |
| Add context to a failure | new Error(msg, { cause: err }) | fmt.Errorf("msg: %w", err) |
| Check the failure kind | instanceof + walk .cause | errors.Is / errors.As |
| Several failures at once | AggregateError | errors.Join |
| Crash on a bug | uncaught throw | panic |
| Catch-all at the top | try/catch around the handler | defer + recover() (same goroutine) |
| Reuse another type's methods | class B extends A | embed A in struct B |
| Customizable step | override a method | pass an interface or func field |
| Cleanup | try/finally | defer |
| "Not found" vs real value | undefined, map.has(k) | v, ok := m[k] |
| Branch on type | typeof / instanceof | type switch x.(type) |
| Same code for many types | (no types — it just works) | generics [T any] |
| Private code | not exported | lowercase name; internal/ package |