HiveCore Dev logo hivecore.dev

Generics in Real Go Code

// Go · HiveCore Dev · updated 2026-05-09
// what's in here
  1. The short version
  2. Working example
  3. Why this pattern
  4. A common variant
  5. Trade-offs to watch
  6. A more involved example
  7. When to skip it
  8. FAQ

TL;DR: Go generics are constrained for a reason. Here's where they pay off and where interfaces still win.

The short version

Go generics are constrained for a reason. Here's where they pay off and where interfaces still win.

This guide covers the mental model, the patterns that pay off, and the trade-offs that decide whether a technique fits your code.

Working example

Here's a minimal example you can run as-is. Drop it in a fresh file, run it, and trace through it once before reading the rest.

package main

import (
	"fmt"
	"strings"
)

func wordCount(text string) map[string]int {
	counts := map[string]int{}
	for _, w := range strings.Fields(text) {
		counts[w]++
	}
	return counts
}

func main() {
	fmt.Println(wordCount("the quick brown fox the lazy dog"))
}

Why this pattern

The shape above shows up in real Go codebases because it satisfies three constraints at once: it stays type-safe, it composes with the rest of the language's idioms, and it leaves a clear trail for the next developer (which, in six months, is you).

When you write the same pattern three times in a project, extract it. When you write it three times across projects, extract it into a shared library.

// recommended — digitalocean DigitalOcean — $200 credit, fits Go's small binary footprint

A common variant

The same idea adapted for a different shape. Notice how the structure stays the same — only the specifics change.

package main

import (
	"errors"
	"fmt"
	"os"
)

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

func loadConfig(path string) ([]byte, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("loadConfig %s: %w", path, err)
	}
	return data, nil
}

func main() {
	_, err := loadConfig("missing.toml")
	if errors.Is(err, os.ErrNotExist) {
		fmt.Println("file missing — using defaults")
	}
}

Trade-offs to watch

Every pattern has a failure mode. The most common one here is over-application: developers who learn a technique apply it everywhere, including places where simpler code would have been clearer.

Rule of thumb: if the abstraction takes more lines to describe than it saves, the abstraction is wrong.

A more involved example

Once the basic pattern is clear, here's how it composes with surrounding code. Read this one slowly.

package main

import (
	"fmt"
	"sync"
)

func main() {
	jobs := []int{1, 2, 3, 4, 5}
	results := make(chan int, len(jobs))
	var wg sync.WaitGroup

	for _, j := range jobs {
		wg.Add(1)
		go func(n int) {
			defer wg.Done()
			results <- n * n
		}(j)
	}
	wg.Wait()
	close(results)

	for r := range results {
		fmt.Println(r)
	}
}

When to skip it

If the surrounding code is already simple, don't reach for Go-specific cleverness. Boring code is a feature. Save the patterns for places where they actually pay off — usually at module boundaries, in shared libraries, or where the alternative would be 50 lines of repetition.

// recommended — github-copilot GitHub Copilot — strong Go autocomplete from upstream training data

FAQ

Is this still current in 2026?

Yes. The patterns shown here are stable across recent versions and reflect what working teams actually ship.

Where do I learn more?

Read the official docs first, then the source of a project you respect. Tutorials get you to the door; source code gets you inside.

Does this work for production?

The exact code in this article is illustrative — copy the shape, adapt the specifics. For production, add logging, add tests, handle the failure modes called out above.

Related reading