Skip to content
Software Atlas

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.

⏱ ~45 min read14 sections7 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

Same story, different language

In one sentenceGo deliberately leaves out classes, inheritance and exceptions. Its patterns are what you build instead: small interfaces, errors you return, and types made out of other types.

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 / catchreturn err / if err != nil§05, §06
an uncaught crashpanic — only for bugs§07
constructor() with defaultsa zero value that already works§09
if (x === undefined)the v, ok := form§09
a utils.js filesmall, 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.

1

The bigger the interface, the weaker the abstraction

Interfaces with one or two methods (io.Reader, fmt.Stringer) are the most useful ones. §02
2

Accept interfaces, return structs

Functions ask for the smallest behaviour they need and hand back concrete types. §03
3

Errors are values

An error is a normal value you return, check, wrap and inspect — not a jump. §05
4

Don't panic

panic is for "this can't happen" bugs, not for "file not found". §07
5

Make the zero value useful

var b bytes.Buffer is ready to use. No constructor needed. §09
+

Composition over inheritance

Build types from other types (§08). Other topics: creational, structural and dependency injection show the classic patterns.
Foundations§02

Interfaces you never declare you implement

In one sentenceIn Go, a type satisfies an interface just by having the right methods. There is no 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:

fmt/print.go (standard library)
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:

temp.go
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:

satisfying an interface
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 Stringer

The 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:

io/io.go (standard library)
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.

io/io.go (standard library)
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 File value 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:

Does it satisfy the interface?
Pick the value you have and the interface a function asks for. The compiler compares the value's method set with the interface's list.
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 receiver
The value you pass:
The interface the function wants:
Method set of File
String() value receiverRead() pointer receiverWrite() pointer receiver
Crossed out: pointer-receiver methods are not in a plain File value's method set.
io.Writer needs
✕ Write() — declared, but on *File
var x io.Writer = File{name: "a.txt"}
❌ Compile error: File does not implement io.Writer (method Write has pointer receiver). Fix: pass & — a pointer, *File.
Practitioner§03

Accept interfaces, return structs

In one sentenceA function should ask for the least it needs (a small interface) and give back the most it has (a concrete struct).

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:

✕ Depend on the concrete type
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.
✓ Declare what you use
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
}
Who owns the interface?
package postgrestype Store structGetInvoice()SaveInvoice()GetUser()Migrate()Close() …package billingtype invoiceStore interfaceGetInvoice() · SaveInvoice()func New(s invoiceStore) *Servicebilling_test.go: fakeStore ✓satisfies it(no import needed)
The 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:

  1. The interface lives with the code that uses it (the consumer), not with the code that implements it. postgres doesn't even know billing exists — implicit interfaces (§02) make that possible.
  2. It lists two methods, not forty. Anything with those two methods works: Postgres, an in-memory map, a cache wrapper, a test fake.
  3. New returns *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:

Plug it in
billing.New(s invoiceStore) is a socket shaped like two methods. Pick something to plug in; the compiler checks the shape.
*postgres.Storeits methods
GetInvoice(ctx, id) (Invoice, error)
SaveInvoice(ctx, inv) error
+ GetUser, Migrate, Close, …
⇄
billing.invoiceStorethe socket
✓ GetInvoice(ctx, id) (Invoice, error)
✓ SaveInvoice(ctx, inv) error
svc := billing.New(pgStore)
✅ 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:

billing/billing.go
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)
}
a test fake
// 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:

fake_test.go
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.)

Practitioner§04

Inside an interface: the nil that isn't nil

In one sentenceA variable of interface type is a box with two slots: which type is inside, and the value. It's 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:

Inside an interface value
An interface variable is a box with two slots. It equals nil only when both slots are empty. Pick a case.
var err error            // declared, nothing assigned
fmt.Println(err == nil)  // ?
err (type error)interface value
type slot
empty
which concrete type is inside
value slot
empty
the data (here: a pointer)
err == nil → true
A fresh interface variable has both slots empty. That — and only that — is what 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":

assert.go
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:

branching on type
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.

Foundations§05

Errors are values

In one sentenceIn Go, a failure is an ordinary value that a function returns, right next to its result. The caller looks at it, like any other return value, and decides what to do.

The shape of every Go call that can fail

reading a config file
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:

Two ways a failure travels up the call stack
JavaScript: throw / catch
handler
try {
waiting inside try…
catch (err) ✓
service
call
skipped
repo
call
skipped
db
query
throw!
Go: return err
handler
call
waiting for return
if err != nil → 404
service
call
wrap: "load profile"
repo
call
wrap: "user 7"
db
query
return err
Top: an exception jumps from 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 != nil is visible in the code. In JS, any line inside try might 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):

built in
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:

making errors
// 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.

Practitioner§06

Wrapping, errors.Is and errors.As

In one sentenceWrap an error to add what you were doing; the original stays inside. Later, 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:

An error wrapped through three layers
Watch the chain grow on the way up, then watch errors.Is walk it back down. Toggle the verb repoGet uses.
Code
1var ErrNotFound = errors.New("not found")
2
3func dbGet(id int) error {
4 return ErrNotFound
5}
6func repoGet(id int) error {
7 if err := dbGet(id); err != nil {
8 return fmt.Errorf("user %d: %w", id, err)
9 }
10 return nil
11}
12func loadProfile(id int) error {
13 if err := repoGet(id); err != nil {
14 return fmt.Errorf("load profile: %w", err)
15 }
16 return nil
17}
18
19err := loadProfile(7) // in the HTTP handler
20fmt.Println(err)
21if errors.Is(err, ErrNotFound) { /* 404 */ }
call stack
handler
loadProfile(7)
repoGet(7)
dbGet(7)
the error value (outer → inner)
no error yet
handler sees
—
The handler calls down three layers. Each function returns an error as an ordinary return value — nothing is thrown.
1 / 8

The result is an Error chain. Two functions walk it from the outside in:

QuestionFunctionTypical 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:

as.go
_, 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.

wrapping
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:

join.go
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: invalid

fmt.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.

Practitioner§07

panic and recover: for bugs, not for flow

In one sentenceReturn an 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

SituationPanic?Why
User sent invalid JSON❌ return an errorexpected; the caller can reply 400
Database is down❌ return an errorexpected in production; retry or fail the request
Index out of range, nil map write✅ Go panics for youa 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… helperfailing 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.

must.go
// 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:

catching the unexpected
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
}
Practitioner§08

Embedding: composition with shortcuts

In one sentenceEmbedding puts one type inside another and lets you call the inner type's methods directly on the outer one. It's a shortcut for composition — not inheritance.

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

✕ Plain field: you must name it
type Service struct {
    log Logger         // named field
}

s.log.Log("started")   // always go through .log
✓ Embedded: methods are promoted
type Service struct {
    Logger             // no field name = embedded
}

s.Log("started")       // shortcut for s.Logger.Log
s.Logger.Log("started") // the long form still works

That'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.)

What embedding really builds
s Serviceown: Name().Base BaseName()Hello()Close().Logger Loggerprefix stringLog()Close()s.Hello()→ s.Base.Hello()s.Log("hi")→ s.Logger.Log("hi")
A 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:

Which method runs?
Click a call. Go looks for the method on Service first, then one level down in its embedded fields.
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] "}}
Service's method setby depth
depth 0Service.Name
depth 1Base.Name (shadowed)Base.HelloBase.CloseLogger.LogLogger.Close
  1. 1Depth 0: Service has no Hello. Depth 1: Base.Hello → promoted.
  2. 2Go rewrites it to s.Base.Hello(). The receiver b is the inner Base value — it has no idea it lives inside a Service.
  3. 3Inside, b.Name() looks up Name on Base, not on Service → Base.Name.
s.Hello() → "hello from base" ← not "service"!

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:

customizable step
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:

✕ Embedded mutex: Lock is public
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 hangs
✓ Named, unexported field
type 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.

Practitioner§09

Everyday idioms: zero values, comma-ok, defer, generics

In one sentenceSmall habits that make Go code read like Go: types that work without setup, a second 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:

ready without setup
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 required

Constructors 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):

server.go
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:

comma_ok.go
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 closed

The 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:

cleanup
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:

generics.go
// 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…WhenExample
an interfacedifferent types behave differently behind the same methodsio.Reader: a file reads from disk, a socket from the network
genericsthe code is identical for every type; only the element type changesslices.Sort, maps.Keys, a typed cache or set
a type switcha 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.

Practitioner§10

Package design and naming

In one sentenceIn Go, the package name is part of every name you export. Choose names that read well with the package in front: 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:

✕ Stutter
package http

type HTTPServer struct{ … }
type HTTPRequest struct{ … }

// caller:  http.HTTPServer, http.HTTPRequest
✓ Reads naturally
package 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

AvoidPreferWhy
util, common, helpers, miscslug, money, retryA name that describes nothing attracts everything: it grows into a pile every package imports
models holding every typetypes live next to the code that uses themsplitting by "kind of code" instead of by feature creates Coupling between everything
userService, user_serviceuserpackage names are short, lowercase, one word, no underscores
types.go, interfaces.goinvoice.go, store.gogroup 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 this

Here 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(), never this or self.
  • 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 — not ServeHttp, userId.
  • No Get prefix on getters: u.Name(), and u.SetName(…) for the setter.
Staff-level§11

Staff-level: idioms at scale

In one sentenceThe idioms above are cheap in one file and expensive to undo across a hundred. At scale, the questions become: who owns this interface, who sees this error, and what am I promising forever?

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 UserService interface, 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:

user/store.go
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.Canceled and context.DeadlineExceeded (check with errors.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:

ChangeSafe?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.

Practitioner§12

Pitfalls

#1

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.

#2

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.

#3

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:

Two variables named err
Each box is a scope. Watch which err gets the failure.
Code
1func run(cond bool) error {
2 var err error
3 if cond {
4 result, err := compute() // := → a NEW err
5 if err != nil {
6 log.Println("compute:", err)
7 }
8 use(result)
9 }
10 return err
11}
function scope
err = nil
if-block scope
(not entered / gone)
returned
—
The function declares err in its own scope (the outer box). It starts as nil.
1 / 5

Fix: declare result beforehand and assign with =, or return inside the block.

#4

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).

#5

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.

#6

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.

#7

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.

#8

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.

#9

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).

Practitioner§13

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.

QUESTION 1 / 6
What does this print?
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)
}
QUESTION 2 / 6
Wrapped or not?
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))
QUESTION 3 / 6
Embedding: which Name?
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())
QUESTION 4 / 6
Does it compile?
type Counter struct{ n int }
func (c *Counter) Inc() { c.n++ }

type Incrementer interface{ Inc() }

var x Incrementer = Counter{}
QUESTION 5 / 6
Map lookup
ages := map[string]int{"ann": 0}
a, okA := ages["ann"]
b, okB := ages["bob"]
fmt.Println(a, okA, b, okB)
QUESTION 6 / 6
Which check finds it?
_, 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))
Answered 0 / 6
Foundations§14

Cheat sheet

🦆
Interfaces are implicitHave the methods, satisfy the interface. Small ones (io.Reader, fmt.Stringer) are the strongest.
📥
Accept interfaces, return structsThe consumer declares the 2-method interface it needs. Constructors return concrete types.
📦
An interface is (type, value)It's nil only when both are empty. Return a literal nil, never a typed nil pointer.
📝
Errors are valuesif err != nil { return …, fmt.Errorf("doing X: %w", err) }. Handle it or return it — not both.
🔍
Is for values, As for typeserrors.Is(err, ErrNotFound), errors.As(err, &pathErr). Never == on wrapped errors, never match on text.
🧯
panic is for bugsExpected failures return errors. Must… for values fixed in code. Recover only at the edge of a goroutine.
🧩
Embedding ≠ inheritancePromoted methods run on the inner value — no overriding from inside. Never embed sync.Mutex in exported types.
0️⃣
Useful zero valuesvar buf bytes.Buffer just works. Write NewX only when a zero value can't be valid.
If you need…JavaScriptGo
A contract between typesduck typing / TS implementsinterface, satisfied implicitly
Report a failurethrow new Error(…)return …, err
Add context to a failurenew Error(msg, { cause: err })fmt.Errorf("msg: %w", err)
Check the failure kindinstanceof + walk .causeerrors.Is / errors.As
Several failures at onceAggregateErrorerrors.Join
Crash on a buguncaught throwpanic
Catch-all at the toptry/catch around the handlerdefer + recover() (same goroutine)
Reuse another type's methodsclass B extends Aembed A in struct B
Customizable stepoverride a methodpass an interface or func field
Cleanuptry/finallydefer
"Not found" vs real valueundefined, map.has(k)v, ok := m[k]
Branch on typetypeof / instanceoftype switch x.(type)
Same code for many types(no types — it just works)generics [T any]
Private codenot exportedlowercase name; internal/ package
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Design PatternsAnti-patterns
Read →