1. What “generics for generics’ sake” means, and why it happens at all
It’s very easy to fall in love with generics for two reasons. First, it looks like “grown-up code”: lots of T, K, V, constraints, beautiful signatures — just like in the standard library (which means it’s obviously correct… right?). Second, generics give you the feeling that you’ve already anticipated everything in advance: “what if I need the same thing, but for a different type”. For beginner developers, this often turns into a reflex: first make it universal, then figure out what you actually wanted.
The problem is that Go is a language where readability and simplicity usually beat “a 5-year architectural vision”. Generics are a tool that should remove duplication and improve the contract, but if they add noise, complicate the call site, and hide intent — you pay complexity right now, and you promise yourself the “benefit” someday later (and often that “later” never comes).
Mini rule of the day: generics should make code cheaper to read
When you read someone else’s code (or your own a week later), your brain usually answers three questions:
- what the function does,
- what its inputs/outputs are,
- what guarantees and constraints exist.
Generics are useful when they make those answers shorter and clearer. For example, (T, bool) instead of any captures the contract very well: “a value may be absent, but if it’s present, it’s the correct type”. But 3–4 type parameters, “clever” constraints, and a comparator function for an operation like “find the first element” most often do the opposite.
Below we’ll go through a few common anti-patterns and treat them “the Go way”: either with the standard library (slices/maps/cmp), or with a simple loop, or with a narrow function for the domain.
2. Anti-pattern #1: “universal” functions that add nothing
There’s a special kind of code that looks smart, but is about as useful as an umbrella in the desert (technically it’s a thing, but why).
A typical example is Identity:
package main
func Identity[T any](v T) T { return v }
func main() {}
Yes, it compiles. Yes, it’s a generic function. But it doesn’t improve the contract, doesn’t hide complexity, and doesn’t make the intent clearer. It just adds a spot in the project where you can “very beautifully” call Identity(x) and feel like an author of the standard library.
In a learning app (let’s continue the thought about our mini task tracker that keeps tasks in memory), such functions usually pop up when you want to “genericize at least something”. If you can’t explain why it was worse without generics (in terms of readability or amount of code), that’s almost always a red flag.
3. Anti-pattern #2: “universality” via any and type assertion
This anti-pattern is especially common among people who used to write Go after working in languages where “everything is an object”. The logic is: “I’ll make a function on any, and then I’ll figure out the types inside”. This turns compile-time errors into runtime errors — meaning you voluntarily give up one of Go’s main advantages: early problem detection.
A bad example of “give me the first element no matter what”:
package main
func FirstAny(xs []any) any {
if len(xs) == 0 {
return nil
}
return xs[0]
}
func main() {}
At first glance it’s convenient: “accepts anything”. In practice, you force every caller to write type checks (or get a panic). And the worst part: the contract becomes blurry. What will be returned? int? string? nil? And in which cases?
A reasonable generics version (which is actually appropriate) looks like this:
package main
func First[T any](xs []T) (T, bool) {
if len(xs) == 0 {
var zero T
return zero, false
}
return xs[0], true
}
func main() {}
Here generics genuinely improved the API: the result type is preserved, and the absence of an element is explicitly expressed via bool. And this is a great example of “generics for meaning”, not for looks.
4. Anti-pattern #3: writing your own “mini-stdlib” while ignoring slices/maps/cmp
It’s very tempting to write “your own utilities package” and start piling everything into it: Clone, Delete, Contains, SortBy, Unique, ToMap, and so on. In the short run it feels like you speed up. In the long run, you invent support for a second standard library, but without the Go team and without a design polished over years.
Meanwhile, Go already has standard generic packages slices, maps, cmp. They appeared not “for beauty”, but as a way to stop manually rewriting the same thing for every type.
For example, instead of a homemade Contains, it’s almost always better to write this:
package main
import (
"fmt"
"slices"
)
func main() {
tags := []string{"go", "cli", "study"}
fmt.Println(slices.Contains(tags, "go")) // true
}
It reads like intent. And most importantly: you’re not fighting the ecosystem — you’re following it.
5. Anti-pattern #4: “copy stdlib signatures and do it like the grown-ups”
Sometimes a student sees a signature from slices and thinks: “Oh, that’s the level. I’ll do the same now”. And writes something where the signature is 5 times longer than the body.
It’s important to understand one psychological point: the standard library can afford a complex signature, because it does it once for the whole Go world, and millions of people get simple call sites.
A good example is slices.Clone. In the standard library it looks roughly like:
func Clone[S ~[]E, E any](s S) S { ... }
Its signature is intentionally built to work correctly not only with []E, but also with named slice types (like type MySlice []string), and to return exactly the same type rather than a “bare” []string.
If you try to replicate this in your own app “just in case”, you usually get the opposite effect: the code becomes heavier to read, and there is no practical benefit.
The same applies to “generic utilities over everything”: when you write Map, FlatMap, Reduce and friends — and instead of a couple of clear loops, a functional mini-language appears in the project that only the author understands. That’s not about Go — it’s about the desire to prove you can do generics.
6. Practice: where generics are not needed
Let’s imagine we have a simple task model for a console “task tracker”. We won’t tie it to files/HTTP/flags — only the data structure and operations on it (all of this you already know from earlier course topics: struct, slice, map, functions, errors).
package main
type Task struct {
ID int
Title string
Done bool
Priority int
}
func main() {}
Now suppose we need to get a list of unfinished tasks. The classic temptation: “I’ll make a universal Filter for any slices!”. But let’s be honest: in an app about tasks, you need a task filter, not a philosophical filter of the universe.
Simple domain code:
package main
func Undone(tasks []Task) []Task {
out := make([]Task, 0, len(tasks))
for _, t := range tasks {
if !t.Done {
out = append(out, t)
}
}
return out
}
func main() {}
This code is instantly readable. If you open the project a week later, you won’t have to remember what Filter[T any] is and which function you passed into it.
And here’s the key idea: sometimes duplication is not evil if it makes the domain transparent. In Go, “reads well” is often more important than “genericizes nicely”.
7. Practice: where you want generics, but it’s better to use slices
Now a different case: we need to sort tasks by priority (for example, ascending). You could write a “universal sorter” that takes an accessor, a comparator, three more generic parameters — and eventually sorts.
But it’s better to keep it simple: sorting is one call to slices.SortFunc, and that’s it.
package main
import (
"cmp"
"slices"
)
func SortByPriority(tasks []Task) {
slices.SortFunc(tasks, func(a, b Task) int {
return cmp.Compare(a.Priority, b.Priority)
})
}
func main() {}
Here cmp.Compare is convenient to use as a building block: it provides a standard comparison for ordered types (for example int).
Notice a nice effect: the code speaks human language — “SortByPriority”. We don’t even try to make “SortBy[T any, K cmp.Ordered]”, because we don’t need to sort “everything in the world” right now. We need to sort tasks.
8. Practice: “let’s genericize Set” — when it can be unnecessary
In an app about tasks, we might want to collect unique values. For example, unique IDs or unique words. And then the thought comes up: “Oh! I’ll make Set[T comparable] and I’ll be cool”.
Sometimes that’s appropriate. But often it’s also “generics for generics’ sake” if you really have just one set of string and you don’t need it anywhere else.
A simple and honest option for unique strings (for example, task tags) — with no generics at all:
package main
func UniqueStrings(xs []string) []string {
seen := make(map[string]struct{}, len(xs))
out := make([]string, 0, len(xs))
for _, x := range xs {
if _, ok := seen[x]; ok {
continue
}
seen[x] = struct{}{}
out = append(out, x)
}
return out
}
func main() {}
Yes, it’s “not universal”. But it’s crystal clear: we unique-ify strings, period.
Why am I pushing clarity so hard? Because generic versions are very easy to bloat: you want to add methods, you want Clone, you want Values, you want it “pretty”. And then you spend time maintaining “collections” instead of solving the app’s problem.
9. Cheat sheet: symptom → what to replace it with
To avoid turning this lesson into “just don’t do that”, let’s lock in practical replacements. This is not a list of “commandments”, but normal engineering logic: what the problem is, what the simple move is, and why it’s better.
| Symptom in the code | What typically happens | What to do instead |
|---|---|---|
| You added Identity, Wrap, ToPtr, FromPtr, and other “do-nothing” generic utilities | The code looks smart, but it doesn’t become shorter or clearer | Delete the utility. Write it directly. If a function doesn’t add meaning, it’s trash — even if it’s “generic” |
| A function takes []any and returns any | Type safety moves to runtime; type assertions start appearing | Either func F[T any](...) (T, bool), or separate functions for the domain |
| You created your own utils/slices instead of slices | You’re maintaining a competitor to stdlib inside the project | Use slices/maps/cmp — that’s exactly what they’re for |
| The signature became longer than the body, like in stdlib | You’re copying “complexity for universality” without understanding why it exists | Keep the simple version. Complex stdlib signatures are often needed for cases like named slices and correct return types |
10. Decision flowchart: should you write generics here?
Sometimes it’s more useful not to memorize rules, but to have a simple “question route”. Here’s a diagram you can actually run in your head:
flowchart TD
A[I want to write a new function] --> B{Is there a repeatable algorithm for at least 2 types?}
B -->|no| C[Write a regular domain-specific function]
B -->|yes| D{Will generics simplify the call site and the contract?}
D -->|no| C
D -->|yes| E{Is there already stdlib: slices/maps/cmp?}
E -->|yes| F[Use stdlib and name the function by intent]
E -->|no| G[Write a generic function, but with a minimal constraint]
The idea is simple: generics are not the “first move”, but one of the tools. And the standard library is the best option when it covers the task, because it already accounts for subtle slice behavior and contracts. For example, the slices package was added specifically to replace “dangerous” hand-rolled patterns like append(s[:i], s[j:]...) with more readable operations.
11. Common mistakes
Mistake #1: “I’ll do generics upfront, just in case”.
Usually this ends with a utils package with a dozen generic functions, but only 1–2 are used, and the rest hang around as a “promise of the future”. Call sites become noisier and reading becomes more expensive. In Go, it’s better to write a concrete function now, and only generalize when a real second usage point appears.
Mistake #2: “I’ll make it universal with any, and figure it out later”.
This disables the compiler as your helper. Instead of a clear build-time error, you get either type assertions everywhere, or runtime panics. If you need the “first element”, a (T, bool) contract is almost always better than any + “just check it yourself”.
Mistake #3: “A signature like slices means it’s correct”.
Stdlib signatures sometimes look complex because they solve ecosystem-scale problems, like correct handling of named slice types and returning the same type rather than the base []E. If you drag that complexity into an app without need, you get “complex for complexity’s sake”.
Mistake #4: “I’ll write my own Delete/Clone/Sort — it’s clearer than stdlib”.
In reality it’s the opposite: slices.Delete, slices.Clone, maps.Clone, cmp.Compare read as a shared language of Go code. Also, the standard implementations account for important slice properties (for example, when an operation should return a new slice, and when it reorders elements in place).
Mistake #5: “I generalized, but lost domain names”.
When Undone(tasks) turns into Filter(tasks, func(t Task) bool { ... }), you lose the semantic hint in the name. And that’s often more important than saving 6 lines. In Go it’s fine to have small concrete functions if they make the code conversational: “what we do” is visible without reading the body.
GO TO FULL VERSION