← Code Like Go

Habits That Travel

part 3 · idioms you steal out of Go and into anything

The first two parts argued the philosophy: Go got good by removing features and the principles that fell out of those removals. This part is for the day job. You are not always writing Go. You are writing Python, TypeScript, Rust, whatever the codebase already is. The point of the series is that the habits are portable even when the language isn't. Every one of these is a Go idiom that makes any codebase more boring, and boring is the feature.

1. Accept interfaces, return structs

Take the smallest abstraction you can; hand back the concrete thing. Your function should ask for the one method it actually uses, not a whole type.

// asks for io.Reader — anything that reads. returns a concrete *Report.
func Summarize(r io.Reader) (*Report, error) {
    data, err := io.ReadAll(r)
    if err != nil {
        return nil, fmt.Errorf("summarize: %w", err)
    }
    return &Report{Bytes: len(data)}, nil
}

Now Summarize works on a file, a network socket, an in-memory buffer, or a fake in a test. You never had to plan for that — it came free because the input was a one-method interface. And because it returns a struct, the caller gets every field with no downcast.

Travels as: depend on the narrowest contract, expose the widest result. In any language: take the protocol/interface/duck the caller can satisfy cheaply, return the full object so callers aren't guessing. Wide inputs couple you to the caller; wide outputs free them.

2. Errors are values — wrap them with context

No exceptions, no stack-unwinding magic. An error is a value you return, check, and decorate as it climbs.

func loadUser(id string) (*User, error) {
    row, err := db.Query(id)
    if err != nil {
        // %w wraps: callers can still errors.Is() the original
        return nil, fmt.Errorf("loadUser %s: %w", id, err)
    }
    return row, nil
}
// failure reads like a trace built by hand:
// loadUser 42: query: connection refused

Each layer adds the one fact it knows — which user, which step — and the bottom error survives for programmatic matching. By the time it reaches a log line it tells a story, not just connection refused with no map of where.

An exception is a goto with better PR. An error value is a thing you can hold.

Travels as: even in languages with exceptions, treat failure as data you enrich, not a missile you throw and forget. Catch at the boundary, add context, rethrow or return with the original attached. Result types (Rust's Result, TS Result<T,E>) are this habit with compiler teeth.

3. Table-driven tests

One test body, a slice of cases. Adding a case is adding a row, not copy-pasting a function.

func TestSlug(t *testing.T) {
    cases := []struct {
        name, in, want string
    }{
        {"basic", "Hello World", "hello-world"},
        {"trim", "  spaces  ", "spaces"},
        {"empty", "", ""},
    }
    for _, c := range cases {
        t.Run(c.name, func(t *testing.T) {
            if got := Slug(c.in); got != c.want {
                t.Errorf("Slug(%q) = %q, want %q", c.in, got, c.want)
            }
        })
    }
}

The cases are now data. You can see coverage at a glance, a new edge case is one line, and each subtest names itself in the output. The test stops being prose and becomes a table you can audit.

Travels as: parametrized tests in every framework — pytest @parametrize, Jest test.each, Rust rstest. Push your cases into a list; keep one body. Fewer test functions, more cases, less rot.

4. Pass context explicitly

Cancellation, deadlines, and request-scoped values ride in a context.Context that is the first argument, threaded by hand all the way down. No hidden thread-locals, no ambient global.

func fetch(ctx context.Context, url string) ([]byte, error) {
    req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
    resp, err := http.DefaultClient.Do(req) // cancels when ctx does
    if err != nil {
        return nil, fmt.Errorf("fetch %s: %w", url, err)
    }
    defer resp.Body.Close()
    return io.ReadAll(resp.Body)
}

When the caller's deadline blows, the cancellation flows through every function that took ctx and the in-flight HTTP call dies on its own. Because it's explicit in the signature, you can see what respects cancellation and what leaks. The plumbing is visible — that is the point.

Travels as: make cancellation and request scope a parameter, not magic. AbortSignal in JS, cancellation tokens in C#, structured concurrency scopes elsewhere. If a long operation can't be killed by its caller, you built a leak.

5. Make the zero value useful

A freshly declared struct should be usable with no constructor. Design so the all-zeros state is a valid, sane default.

type Buffer struct{ buf []byte } // var b Buffer  -> ready to use

var b bytes.Buffer       // no New(), no init
b.WriteString("hello")   // works on the zero value

var mu sync.Mutex        // zero value is an unlocked mutex
mu.Lock()

No half-initialized objects, no “did you call .init()?” class of bug. The empty state is the safe state.

Travels as: pick defaults so the un-configured object already works. Sensible defaults over required setup. Every constructor you can delete is a missed-initialization bug you can't ship.

6. Standard library first

Reach for stdlib before go get. Go's standard library does HTTP servers, JSON, crypto, templating, and testing with no third party. A dependency is a liability you don't own: its CVEs, its breaking changes, its supply chain.

// a real HTTP server, zero dependencies
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "ok")
})
log.Fatal(http.ListenAndServe(":8080", nil))

Travels as: before npm install / pip install, ask if the platform already does it. The left-pad lesson is permanent. The cheapest dependency is the one you never added.

7. Channels vs mutexes — pick the simpler

Go gives you both CSP-style channels and plain mutexes, and the proverb is blunt: don't communicate by sharing memory; share memory by communicating — but only when that's actually clearer.

// channel: ownership moves with the value, no lock to forget
results := make(chan int)
go func() { results <- expensive() }()
v := <-results

// mutex: when you just guard one counter, this is simpler. use it.
var (
    mu sync.Mutex
    n  int
)
mu.Lock(); n++; mu.Unlock()

A channel shines when work or ownership flows between goroutines. A mutex wins when you're guarding one piece of shared state in place. Reaching for a channel to protect a single integer is cleverness; reaching for a mutex to model a pipeline is pain. Pick the one that reads simpler at the call site.

Travels as: message-passing (actors, queues, Web Workers) vs locks exists in every concurrent runtime. The rule is the same: whichever makes the code obvious to the next reader. Concurrency is where clever goes to cause 3am pages — default to whatever's boring.

The throughline

None of these need Go to run. They need the discipline Go enforces by removing the alternatives. Narrow inputs. Errors you can hold. Tests that are tables. Cancellation you can see. Defaults that just work. Dependencies you didn't add. Concurrency that stays legible. Each one is a removal that accelerated — one less way to be wrong, one fewer argument in review, one faster ship.

If you do one thing: write code the way you'd write Go — even when you're not writing Go.

Back to the argument that earns all this in Subtraction and Principles. Forward to where it paid off and where ignoring it burned in Stories.