Back to blog

Sunday, October 25, 2026

Why Do Teams Overuse Generics In Go?

Title card for 'Why Do Teams Overuse Generics In Go?' with a short code example

Introduction

Teams overuse generics in Go when they reach for a type parameter before they have a second type that needs the same code. Generics are a tool for removing duplicated code that differs only by type, not a default shape for every function, struct, or service. When a team adds type parameters early, the code gains an abstraction that no caller asked for, and every later reader pays for it.

Before You Start

  • Type parameter: a placeholder for a type, written in square brackets, such as T in func First[T any](items []T) T. Calling code supplies a type argument, such as int or string, and the compiler checks the call against the constraint.
  • Type argument: the concrete type supplied for a type parameter at a call site or at a type instantiation, such as string in Store[string].
  • Constraint: the rule that limits which types may be used as a type argument. A constraint is written as an interface, and it may list methods, types, or both.
  • any: a predeclared constraint that permits every type. It is an alias for the empty interface, and it says nothing about what the code may do with a value.
  • comparable: a predeclared constraint that permits types whose values may be compared with == and !=. Map keys must be comparable, so a generic map helper often constrains its key type this way.
  • Interface: a type that lists a set of methods. A concrete type satisfies an interface when it implements every method in the set. An interface describes behaviour, not a list of types.
  • Concrete type: a named type with a fixed definition, such as AuditEntry or User. A concrete type has known fields, known methods, and a known meaning in the domain.
  • Overuse: using a type parameter where a concrete type, a small interface, or a duplicated function would be clearer. Overuse is a design judgement, not a compiler error, and this article gives the signals for making that judgement.

Prerequisites

  • Go 1.20 or later, because the article uses type parameters, constraints, and interfaces in their stable form.
  • A terminal, and a text editor.
  • No third party packages are required for the complete example.
go version

Requires: Go 1.20 or later. Verified on 2026-10-25 against the official sources in References.

Project Layout

team-generics/
├── go.mod        # module declaration, Go version
├── main.go       # generic store, concrete log, and interface notification
└── main_test.go  # store, log, and notification tests
  • go.mod: declares the module and the minimum Go version.
  • main.go: holds both versions of the same small problem, so the contrast can be read side by side.
  • main_test.go: holds tests for the generic store, the concrete log, and the interface notification.

Why Generics Tempt Teams

Generics tempt teams because they look like a way to write code once and never revisit it. A team writes a helper for one type, imagines a second type arriving soon, and generalises the helper in advance. The helper then looks finished, even though the second type never arrives, or arrives with different rules.

A second temptation is symmetry. Once one part of a codebase uses a type parameter, nearby code starts to match it, and a generic wrapper grows around concrete code. A repository, a service, and a handler each gain a type parameter, and the parameter travels through every layer, even though only the innermost layer varies by type.

A third temptation is the fear of duplication. Duplication has a bad reputation, so a team treats two similar functions as a problem to be solved at once, rather than as evidence to be read. Two functions that look alike today may diverge tomorrow, when validation, error handling, or storage rules differ by type. Generalising before that divergence is known removes the room for divergence, and the team then adds hooks, options, or extra type parameters to recover it.

The Go team's own guidance cuts against all three temptations. Its advice is to start by writing functions, and to add type parameters later, when it is clear that they will be useful. The closing guideline in that same post is the test this article uses throughout: avoid type parameters until you notice that you are about to write the exact same code multiple times, where the only difference between the copies is that the code uses different types.

When Generics Earn Their Place

Generics earn their place when the code is the same for every type, and the type itself carries no behaviour that the code depends on. The Go team's post names three such cases, and they are worth stating plainly, because they set the bar for the rest of this article.

First, functions that operate on language defined container types, such as slices, maps, and channels, where the code makes no particular assumption about the element type. A function that works over a map of any key and value type, without using the value type at all, is a candidate, because the logic is independent of the types involved.

Second, general purpose data structures, such as a linked list or a binary tree, where the structure and its methods are largely independent of the element type. Replacing a specific element type with a type parameter makes the structure usable in more places, and replacing an interface element type with a type parameter avoids type assertions and keeps the code fully type checked at build time.

Third, cases where different types need to implement a common method, and the implementations for the different types all look the same. The post's example is a generic type that implements a sorting interface for any slice type, where the length and swap methods are identical for every slice, and only the comparison is supplied as a function.

Notice what those three cases share. The logic does not change with the type. The type parameter removes duplication that already exists, in code that already runs, for types that already exist. A type parameter added for a type that might arrive later fails that test, and a type parameter added for code whose logic differs by type fails it too.

The question of why the standard library has no built-in generic map and filter is covered in Why Does Go Have No Built-In Generic Map And Filter?.

Why generic code can feel like a type puzzle, with constraints, inference, and satisfaction rules, is covered in Why Do Go Generics Feel Like A Type Puzzle?.

Signals Of Overuse

Overuse has signals that a team can check in a review, without waiting for the code to become painful. Each signal below is a reason to pause and ask whether a concrete type or an interface would serve better.

  • Only one instantiation exists: a generic type or function is used with exactly one type argument, everywhere, for a long time. The type parameter is then a promise of generality that no caller uses.
  • The type parameter appears once: a function declares T and uses it for a single argument or a single return value, with no relationship between two values that the parameter enforces. A concrete type would say the same thing, with a name the reader recognises.
  • The constraint is any, and the body switches on type: a function accepts every type, then uses a type switch or reflection to handle each type differently. That is behaviour that differs by type, which belongs in an interface or in separate functions, not in a generic function.
  • Method implementations differ by type: two types need the same method name, but the bodies do different work. The Go team's post is direct on this point: if the implementation is different for each type, use an interface type and write different method implementations, and do not use a type parameter.
  • The constraint grows with every new caller: each new use adds a method, a type, or a second type parameter to the constraint, so the constraint becomes a list of everything any caller might need. A constraint that grows this way is a sign that the callers do not share one abstraction.
  • Callers repeat type arguments everywhere: a struct is instantiated with the same type arguments in every file, and every function that touches it carries the same parameters. The parameter has become boilerplate, not flexibility.
  • The generic wrapper only forwards: a generic service, repository, or handler adds no logic of its own, and only passes values to concrete code underneath. The wrapper then costs a type parameter at every layer, for no behaviour of its own.
  • Errors name the machinery, not the domain: a compiler error or a failed test talks about constraints, type arguments, and satisfaction, while the reader is trying to understand a user, an order, or an audit entry. That gap is a readability cost, and it grows with each parameter.

No single signal proves overuse on its own. Two or more together, in the same helper, are a strong case for pulling back.

The Cost Of An Over Generic API

An over generic API costs more than the lines it adds, and the costs are qualitative, not measured numbers. This article makes no claim about compile times, adoption rates, or team sizes, because no primary source for such numbers was found in this run, and the argument does not need them.

First, every type parameter becomes part of the public API. Changing a constraint, adding a parameter, or narrowing a permitted type breaks callers, in the same way that changing a function signature does. A concrete type can change its internals without changing its name, but a generic type carries its parameters in its name, so a change to the parameters is a change to the name.

Second, reading cost rises with each parameter. A reader of a concrete function sees AuditEntry and knows what fields and methods it has. A reader of a generic function sees T, then must find the constraint, then must check which operations the constraint permits, then must find the call site to learn which concrete type is actually used. That search repeats for every function in the chain.

Third, behaviour that differs by type has nowhere clean to live. A generic function cannot branch on type without a type switch, and a generic type cannot give one instantiation a different method body from another. The team then adds a function field, a hook, or an options struct, and the generic shape that was meant to remove code now carries extra machinery to recover the differences it hid.

Fourth, interfaces become harder to write. An interface that needs to describe a generic type must either carry the same type parameters, which spreads them further, or accept any, which drops the type safety the generic code was meant to provide. A concrete type, by contrast, can be described by a small interface that lists only the methods a caller needs.

Fifth, testing becomes less direct. A test for a concrete type constructs that type, calls its methods, and checks its behaviour. A test for a generic type must choose an instantiation, and a test that only ever instantiates one type is testing a concrete type through a generic shape, with the extra syntax and no extra coverage.

None of these costs means generics are bad. They mean generics are a purchase, and the price is paid by every reader, every caller, and every future change to the API. A team should pay that price when the duplicated code it removes is real, repeated, and identical, and not before.

Pulling Back To Concrete Types Or Interfaces

Pulling back is a small refactor, and it works best in order, from the most specific step to the most general one.

Step one is to write the concrete version first, for the type that exists today. Name the type after the domain, not after the pattern, so AuditLog rather than Store[AuditEntry]. Give it the methods the domain needs, and let validation, error handling, and storage rules live where they belong, in that type.

Step two is to ask whether a second type needs the same code, with the same logic, and whether that code already exists twice. If the answer is no, stop. The concrete version is the finished version, and the team has avoided an abstraction that no caller needs.

Step three is to ask whether the shared part is behaviour or structure. If different types need the same method name with different bodies, write an interface that lists the method, and write one implementation per type. If the shared part is structure, such as holding values by key, and the logic is identical for every type, then a type parameter is the right tool, and it should be introduced at that point, not before.

Step four is to narrow the generic version, if one already exists and must stay. Remove type parameters that are used once, replace a parameter that only ever holds one type with that type, and replace a constraint that lists methods with a small interface where the caller only calls those methods. Each removal shortens the API surface, and each removal is a separate, reviewable change.

Step five is to keep the generic code close to its use. A generic helper used by one package does not need to live in a shared package with a public name, and a generic type used by one service does not need to wrap every layer above it. Let the generic code stay where its duplication lives, and let concrete types carry the domain names at the boundary.

A team that follows those steps will still use generics, but will use them where they remove real duplication. The codebase will have fewer type parameters, and each one that remains will have a reason a reviewer can state in one sentence.

Design Decisions And Tradeoffs

OptionUse whenCost
Concrete typeOne type exists, or the logic differs by type in validation, errors, or storageDuplication if a second type later needs identical logic, which is then a known, visible duplication
Small interfaceCallers only need a set of methods, and implementations differ by typeCallers cannot see the concrete type, so construction and domain fields stay behind the interface
Generic function or typeThe same code already exists for two or more types, with identical logicEvery parameter becomes part of the API, reading cost rises, and changing a constraint breaks callers
Generic wrapper around concrete codeA shared container or algorithm truly varies only by element typeThe wrapper adds a parameter at every layer it crosses, for no behaviour of its own
Duplicated functionsTwo types look alike today, but their rules are expected to divergeDuplication is visible, and must be revisited when a third type arrives
Type switch or reflection inside a generic functionBehaviour must differ by type, and no interface fitsThe generic shape hides the branching, and the compiler cannot check every branch at build time
Constraint with many methods or parametersCallers genuinely share a large, stable contractThe constraint becomes a second API to maintain, and new callers must satisfy all of it

A design in this article has a deliberate compromise, stated openly. The complete example uses one record type, AuditEntry, for both the generic and the concrete version, so the contrast can be read without a second domain. In a real codebase, the generic version would earn its place only when a second record type uses the same store logic unchanged. The single type here is a teaching compromise, not a claim that one type supports a generic store. The decision rule itself is not compromised: identical logic, for types that already exist, is the bar.

Complete Example

The complete example is a small store that holds records by ID and returns one by ID. The generic version holds any record type, with a key function that extracts the ID. The concrete version holds audit entries only, with a loop lookup. The interface version shows behaviour that differs by type, where an interface, not a generic function, is the right tool. The main function runs all three and prints the same record, so the reader can see that the generic version buys nothing for this problem, while the concrete version says what it holds.

// go.mod
module teamgenerics

go 1.20
// main.go
package main

import "fmt"

type AuditEntry struct {
    ID      string
    Message string
}

type KeyedStore[T any] struct {
    items map[string]T
    key   func(T) string
}

func NewKeyedStore[T any](key func(T) string) *KeyedStore[T] {
    return &KeyedStore[T]{items: make(map[string]T), key: key}
}

func (s *KeyedStore[T]) Add(item T) {
    s.items[s.key(item)] = item
}

func (s *KeyedStore[T]) Get(id string) (T, bool) {
    item, ok := s.items[id]
    return item, ok
}

type AuditLog struct {
    entries []AuditEntry
}

func (l *AuditLog) Add(entry AuditEntry) {
    l.entries = append(l.entries, entry)
}

func (l *AuditLog) Get(id string) (AuditEntry, bool) {
    for _, entry := range l.entries {
        if entry.ID == id {
            return entry, true
        }
    }
    return AuditEntry{}, false
}

type Notifier interface {
    Notify() string
}

func (e AuditEntry) Notify() string {
    return "audit: " + e.Message
}

func SendNotification(n Notifier) string {
    return n.Notify()
}

func main() {
    generic := NewKeyedStore(func(e AuditEntry) string { return e.ID })
    generic.Add(AuditEntry{ID: "a1", Message: "deploy finished"})
    concrete := &AuditLog{}
    concrete.Add(AuditEntry{ID: "a1", Message: "deploy finished"})
    fromGeneric, _ := generic.Get("a1")
    fromConcrete, _ := concrete.Get("a1")
    fmt.Println(fromGeneric.Message)
    fmt.Println(fromConcrete.Message)
    fmt.Println(SendNotification(AuditEntry{ID: "a2", Message: "backup finished"}))
}

Commands to run it, from the team-generics folder:

go run .

Expected output:

deploy finished
deploy finished
audit: backup finished

Why the concrete version wins for this problem, in order:

  • The generic store and the concrete log return the same record, for the same input, with the same behaviour. The generic version adds a type parameter and a key function, and neither changes the result.
  • The concrete log names its domain in its type name, its field name, and its method signatures, so a reader knows what it holds without finding a call site.
  • The interface version, Notifier, shows the case where generics would be wrong: if a second record type needed a different notification message, it would implement Notify differently, and a generic function could not give it a different body. An interface lets each type carry its own behaviour.
  • The generic store would earn its place only when a second record type uses the same keyed storage, with the same logic, and the team is tired of writing the same store twice. Until that day, the concrete log is the finished version.

A fragment of the overuse pattern, for contrast, shows a generic repository that looks tidy and then grows:

type Repository[T any] struct {
    items map[string]T
    key   func(T) string
    validate func(T) error
    onSave func(T)
}

Each new difference between record types becomes another function field, and the repository that was meant to remove code now carries a configuration surface of its own. A concrete repository per record type would hold its validation and its save hook as ordinary methods, with names a reader can find.

Testing

Test the generic store, the concrete log, and the interface notification separately, so a failure names the version that broke, not the pattern in general.

// main_test.go
package main

import "testing"

func TestKeyedStoreGetAndMiss(t *testing.T) {
    store := NewKeyedStore(func(e AuditEntry) string { return e.ID })
    store.Add(AuditEntry{ID: "a1", Message: "deploy finished"})
    got, ok := store.Get("a1")
    if !ok {
        t.Fatal("Get(a1) ok = false, want true")
    }
    if got.Message != "deploy finished" {
        t.Errorf("Get(a1) message = %q, want %q", got.Message, "deploy finished")
    }
    _, ok = store.Get("missing")
    if ok {
        t.Errorf("Get(missing) ok = true, want false")
    }
}

func TestAuditLogGetAndMiss(t *testing.T) {
    log := &AuditLog{}
    log.Add(AuditEntry{ID: "a1", Message: "deploy finished"})
    got, ok := log.Get("a1")
    if !ok {
        t.Fatal("Get(a1) ok = false, want true")
    }
    if got.Message != "deploy finished" {
        t.Errorf("Get(a1) message = %q, want %q", got.Message, "deploy finished")
    }
    _, ok = log.Get("missing")
    if ok {
        t.Errorf("Get(missing) ok = true, want false")
    }
}

func TestSendNotification(t *testing.T) {
    got := SendNotification(AuditEntry{ID: "a2", Message: "backup finished"})
    want := "audit: backup finished"
    if got != want {
        t.Errorf("SendNotification = %q, want %q", got, want)
    }
}

Commands to run the tests and the static check:

go vet ./...
go test ./...

Expected output for the tests is a pass for every test, because the generic store, the concrete log, and the notification interface return the values shown in the complete example. go vet reports suspicious constructs. It does not judge whether a type parameter is warranted, so do not treat a clean vet run as proof that a generic API is the right shape. The decision rule in this article is the proof for that question, and the tests are the proof that the code behaves as described.

Common Mistakes

  • Generalising before a second type exists: a team writes a generic helper for one type, on the expectation that a second type will arrive. The fix is to write the concrete version first, and to generalise only when the second type arrives with identical logic.
  • Replacing an interface with a type parameter: a function that only calls a method on its argument is rewritten with a type parameter constrained to that method, even though the interface version is shorter and clearer. The fix is to keep the interface, as the Go team's post advises for io.Reader style cases.
  • Using a type parameter for behaviour that differs by type: a generic function is expected to handle each type differently, so it grows a type switch, a hook, or a reflection branch. The fix is an interface with one implementation per type, or separate functions per type.
  • Letting a constraint grow without review: each new caller adds a method or a type to a constraint, until the constraint describes no single abstraction. The fix is to split the constraint, or to replace it with a small interface that lists only what the function actually calls.
  • Wrapping every layer in the same parameter: a repository, a service, and a handler all carry T, even though only the repository varies by type. The fix is to keep the generic code where its duplication lives, and to use concrete types at the boundary.
  • Treating duplication as a defect on sight: two similar functions are merged at once, before their differences are understood, and the merged version then needs hooks to recover those differences. The fix is to leave the duplication visible until a third copy, or a clear identical pattern, shows that merging is safe.
  • Choosing generics for speed: an interface is replaced with a type parameter in the hope of faster code, without a measurement or a reason from the language documentation. The Go team's post warns against this change, because a type parameter will probably not run any faster than an interface value.
  • Hiding domain names behind single letters: a codebase full of T, K, and V forces every reader to find call sites to learn what is actually stored. The fix is to use concrete domain names at the boundary, and to reserve type parameters for code where the element type truly does not matter.
  • Adding a generic type to avoid a small interface: a team writes a generic container rather than a small interface, because the container looks more reusable, even though the caller only needs two methods. The fix is to write the small interface first, and to add the container only when its structure is reused.
  • Keeping a generic API because it already exists: a generic helper that no longer has two callers stays generic, because removing a parameter feels like a breaking change. The fix is to treat the removal as ordinary API maintenance, with a migration for the one caller that remains, rather than carrying the parameter forever.

Production Notes

  • API stability: every type parameter and constraint is part of a public API, so changing one is a breaking change for callers. Keep generic APIs small, and keep them in packages where the team accepts that stability cost.
  • Review rule: a review for a new type parameter should ask for the second type, the duplicated code it removes, and the identical logic it preserves. If the author cannot name the second type, the parameter should not land yet.
  • Migration: pulling back from a generic API is a migration, not a deletion. Change one caller at a time, keep the old generic name as a wrapper only while callers move, and remove the wrapper when the last caller has moved. A wrapper kept forever is the overuse pattern in a new form.
  • Documentation: a generic function needs a doc comment that states what the type parameter may be, what the constraint permits, and what the function does with values of that type. A concrete function needs only the last of those, because its type name already states the first two.
  • Observability: overuse shows up in reviews and in onboarding time, not in metrics. If new team members ask what T holds in every file, or if a change to one record type forces a change to a shared generic layer, the abstraction is costing more than it saves.
  • Deployment: the example needs Go 1.20 or later. Pin that version in go.mod, run go vet ./... and go test ./... in continuous integration, and do not deploy a change that fails those checks. A vet pass alone does not judge whether a type parameter is warranted.

Decision Guide

  • When the same code already exists for two or more types, with identical logic: use a type parameter, and keep the constraint as small as the code requires.
  • When one type exists today, and a second type is only expected: write the concrete version, and revisit the decision when the second type arrives.
  • When callers only call methods on a value, and implementations differ by type: use an interface, and write one implementation per type.
  • When a function only needs to hold, move, or return values, without depending on their behaviour: a generic container or a generic function over a language defined container type is a candidate.
  • When a constraint needs more than two type parameters, or a long method list: pause, and check whether a small interface, a concrete type, or a split into two functions serves better.
  • When a generic wrapper only forwards to concrete code: remove the wrapper, and let the concrete code carry the domain names.
  • When behaviour must differ by type, and no interface fits: use a type switch or reflection deliberately, with a comment stating why, rather than hiding the branching inside a generic shape.

Conclusion

Teams overuse generics when they generalise before they have duplication to remove. A type parameter is a good tool for code that is already the same for several types, and a poor tool for code that might one day be similar, or for behaviour that differs by type. Concrete types name the domain, small interfaces carry behaviour that varies, and generics carry structure that stays the same.

Summary Of Key Points

  • Start concrete: write functions and concrete types first, and add type parameters only when identical code already exists for several types.
  • Identical logic is the bar: generics earn their place when the logic does not change with the type, not when the shapes look alike.
  • Interfaces carry behaviour: when method implementations differ by type, use an interface with one implementation per type.
  • Signals warn early: one instantiation, a growing constraint, a wrapper that only forwards, and errors that name the machinery are signs to pull back.
  • Parameters are API: every type parameter and constraint is part of the public API, and changing one breaks callers.
  • Pull back in order: concrete type first, interface for varying behaviour, generic only for identical structure, and narrow or remove parameters that no longer earn their place.

Follow those rules, and generics stay a tool for removing real duplication, rather than a default shape that every new helper must wear.

Additional Resources

  • Go Blog: When To Use Generics - the Go team's guidance on when type parameters help, when interfaces serve better, and the guideline to avoid type parameters until identical code is about to be written multiple times
  • Go Documentation: Tutorial: Getting started with generics - type parameters, type arguments, type constraints, and the comparable constraint, with a worked map summing example
  • Go Packages: builtin package - the predeclared any and comparable constraints, and the built in types and functions that generic code builds on

References

  1. When To Use Generics. Ian Lance Taylor, Go Blog. https://go.dev/blog/when-generics. Accessed 2026-10-25.
  2. Tutorial: Getting started with generics. Go team, Go Documentation. https://go.dev/doc/tutorial/generics. Accessed 2026-10-25.
  3. builtin package. Go team, Go Packages. https://pkg.go.dev/builtin. Accessed 2026-10-25.