Why Generics in Golang Is a Feature Every Engineer Must Master
Go is known as a pragmatic language — it doesn’t add new features unless there’s a strong reason. When generics finally arrived in Go 1.18 after years of community debate, it wasn’t a light decision. The Go team spent almost a decade finding the right design: expressive enough to solve real problems, but not so complex that it ruins Go’s simplicity.
The result is a generics system different from Java, C++, or Rust — more limited in some ways, but actually easier to understand and predict. Understanding generics in Go isn’t about learning an unfamiliar new feature; more precisely, it’s a new way of expressing patterns you’ve been doing all along, but with code duplication or by sacrificing type safety.
This article discusses generics from the foundations to real production patterns — including the limitations that often surprise experienced developers from other languages.
The Go World Before Generics: Two Equally Bad Paths
To understand why generics exist, we need to see the problem Go developers had to solve before Go 1.18.
Imagine you need to write a Contains function that checks whether a value exists in a slice. Before generics, there were two options:
Option 1: Code Duplication per Type
// ANTI-PATTERN: duplication for every type
func ContainsInt(slice []int, val int) bool {
for _, v := range slice {
if v == val {
return true
}
}
return false
}
func ContainsString(slice []string, val string) bool {
for _, v := range slice {
if v == val {
return true
}
}
return false
}
func ContainsFloat64(slice []float64, val float64) bool {
for _, v := range slice {
if v == val {
return true
}
}
return false
}
// ... and so on for every type
The logic is identical. Only the type differs. But you have to write — and maintain — three separate functions. Add one new type, add one more function. A bug in one function must be fixed in all of them.
Option 2: Using interface{} or any
// ANTI-PATTERN: losing type safety
func Contains(slice []interface{}, val interface{}) bool {
for _, v := range slice {
if v == val {
return true
}
}
return false
}
// Usage — looks like it works, but there are hidden costs
ints := []interface{}{1, 2, 3, 4, 5}
Contains(ints, 3) // works
// But this is also syntactically valid — and possibly not what you want
Contains(ints, "three") // no compile error, but wrong logic
With interface{}, you lose the compiler’s ability to catch type errors at compile time. Runtime panics or unexpected behavior become more likely. Plus there’s boxing/unboxing overhead for primitive types.
The Solution with Generics
// CORRECT: one implementation, type-safe for all comparable types
func Contains[T comparable](slice []T, val T) bool {
for _, v := range slice {
if v == val {
return true
}
}
return false
}
// All of these are valid and type-safe
Contains([]int{1, 2, 3}, 2) // ✓
Contains([]string{"a", "b"}, "b") // ✓
Contains([]float64{1.1, 2.2}, 3.3) // ✓
// This will be rejected by the compiler — type mismatch
Contains([]int{1, 2, 3}, "two") // ✗ compile error
This is the core value of generics: one implementation, full type safety, no duplication.
Basic Syntax: Type Parameters
Type parameters are the heart of generics. They use square brackets [T Constraint] placed after the function or type name.
// Anatomy of a generic function
func FunctionName[TypeParam Constraint](param TypeParam) TypeParam {
// implementation
}
Let’s break down each part:
func Identity[T any](val T) T {
return val
}
T— the type parameter name. The convention uses a single capital letter (T,K,V,E) but any descriptive name worksany— the constraint.anyis an alias forinterface{}— meaning any type is acceptedval T— a function parameter of typeTT(after the closing bracket) — the return type, also of typeT
Calling a Generic Function
Go supports type inference — the compiler can infer the type argument from the given arguments:
// Explicit — specifying the type argument manually
Identity[int](42)
Identity[string]("hello")
// Inferred — the compiler infers the type from the arguments
Identity(42) // the compiler knows T = int
Identity("hello") // the compiler knows T = string
In daily practice, type inference is almost always used because it’s more concise. Explicit type arguments are needed only when inference fails or for clarity.
Constraints: Defining the Bounds of Type Parameters
A constraint is the contract defining what operations can be performed on a type parameter. This is what makes Go’s generics different from C++ templates, which are more “free” — constraints must be explicitly declared.
any — All Types
// T can be any type
func Ptr[T any](val T) *T {
return &val
}
With any, you can only perform operations valid for all types: assign to a variable, pass to another function, or return. You can’t do val + val because not all types support +.
comparable — Comparable Types
// T must be comparable with == and !=
func IndexOf[T comparable](slice []T, val T) int {
for i, v := range slice {
if v == val {
return i
}
}
return -1
}
comparable is a built-in Go constraint covering all types usable as map keys: numbers, strings, bools, pointers, structs with all comparable fields.
Interfaces as Constraints
// Constraint with method requirements
type Stringer interface {
String() string
}
func Print[T Stringer](val T) {
fmt.Println(val.String())
}
Any Go interface can be used as a constraint. This means the type parameter must implement that interface.
Union Types with |
This is a new feature only valid as a constraint — it can’t be used as a regular type:
// A constraint accepting one of several concrete types
type Integer interface {
int | int8 | int16 | int32 | int64
}
type Float interface {
float32 | float64
}
type Number interface {
Integer | Float
}
func Sum[T Number](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
With union types, you can use the +, -, *, / operators because all types in the union support them.
The Tilde ~: Constraints for Matching Underlying Types
The tilde is one of the most important concepts that’s often missed:
// Without the tilde — ONLY accepts the exact type int
type OnlyInt interface {
int
}
// With the tilde — accepts int AND all types whose underlying type is int
type IntLike interface {
~int
}
Why is this important? Because in Go it’s very common to define custom types based on primitive types:
type UserID int
type OrderID int
type Celsius float64
type Fahrenheit float64
// ANTI-PATTERN: without the tilde, custom types aren't accepted
type Number interface {
int | float64
}
func Double[T Number](v T) T { return v + v }
var temp Celsius = 36.6
Double(temp) // ✗ compile error: Celsius doesn't satisfy the Number constraint
// CORRECT: with the tilde, custom types with the same underlying type are accepted
type Number interface {
~int | ~float64
}
func Double[T Number](v T) T { return v + v }
var temp Celsius = 36.6
Double(temp) // ✓ Celsius's underlying type is float64
Almost always use ~ when the constraint involves primitive types, unless you truly only want to accept the exact type.
Multiple Type Parameters
Functions and types can have more than one type parameter:
// Two independent type parameters
func Map[T any, R any](slice []T, fn func(T) R) []R {
result := make([]R, len(slice))
for i, v := range slice {
result[i] = fn(v)
}
return result
}
// Three type parameters — for key-value mapping
func Associate[T any, K comparable, V any](
slice []T,
keyFn func(T) K,
valFn func(T) V,
) map[K]V {
result := make(map[K]V, len(slice))
for _, v := range slice {
result[keyFn(v)] = valFn(v)
}
return result
}
Example usage of Associate:
type User struct {
ID int
Name string
Age int
}
users := []User{
{1, "Alice", 30},
{2, "Bob", 25},
{3, "Charlie", 35},
}
// Map from ID to name
nameByID := Associate(users,
func(u User) int { return u.ID },
func(u User) string { return u.Name },
)
// nameByID = map[int]string{1: "Alice", 2: "Bob", 3: "Charlie"}
Generic Structs
Type parameters aren’t only for functions — structs can also be generic:
// A generic wrapper that can store a value of any type
type Optional[T any] struct {
value T
hasValue bool
}
func Some[T any](val T) Optional[T] {
return Optional[T]{value: val, hasValue: true}
}
func None[T any]() Optional[T] {
return Optional[T]{}
}
func (o Optional[T]) Get() (T, bool) {
return o.value, o.hasValue
}
func (o Optional[T]) OrElse(defaultVal T) T {
if o.hasValue {
return o.value
}
return defaultVal
}
Usage:
name := Some("Alice")
val, ok := name.Get() // "Alice", true
empty := None[string]()
val2 := empty.OrElse("unknown") // "unknown"
Generic Structs for API Responses
This pattern is very common in Go backends:
type Response[T any] struct {
Data T `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Success bool `json:"success"`
}
func NewSuccessResponse[T any](data T) Response[T] {
return Response[T]{Data: data, Success: true}
}
func NewErrorResponse[T any](err string) Response[T] {
return Response[T]{Error: err, Success: false}
}
Now every handler can use the same wrapper:
// Handler for users
func GetUser(w http.ResponseWriter, r *http.Request) {
user := User{ID: 1, Name: "Alice"}
resp := NewSuccessResponse(user)
json.NewEncoder(w).Encode(resp)
// {"data":{"id":1,"name":"Alice"},"success":true}
}
// Handler for products — the response structure is the same, the data type differs
func GetProduct(w http.ResponseWriter, r *http.Request) {
product := Product{ID: "P001", Name: "Laptop"}
resp := NewSuccessResponse(product)
json.NewEncoder(w).Encode(resp)
}
Composite Constraints: Combining Multiple Requirements
A constraint can combine method requirements and union types:
// A type that's orderable AND has a String() method
type OrderedStringer interface {
~int | ~float64 | ~string
String() string
}
But be careful — constraints like this are very strict and few types satisfy them. It’s more common to separate constraints:
// More flexible: separate constraints for different operations
type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64 | ~string
}
func Min[T Ordered](a, b T) T {
if a < b {
return a
}
return b
}
func Max[T Ordered](a, b T) T {
if a > b {
return a
}
return b
}
func Clamp[T Ordered](val, lo, hi T) T {
return Max(lo, Min(hi, val))
}
The package golang.org/x/exp/constraints provides these standard constraints (including Ordered) so you don’t need to define them yourself.
Type Inference: When It Works and When It Doesn’t
Type inference in Go works by analyzing the types of the given arguments:
// All of these use type inference
nums := []int{3, 1, 4, 1, 5}
Min(3, 7) // T = int
Map(nums, strconv.Itoa) // T = int, R = string (from Itoa's signature)
Contains([]string{"a", "b"}, "c") // T = string
Type inference fails in some situations:
// Fails: the return type can't be inferred from the arguments
func Zero[T any]() T {
var zero T
return zero
}
Zero() // ✗ compile error: can't infer T
Zero[int]() // ✓ must be explicit
// Fails: types in a struct literal
type Pair[A, B any] struct{ First A; Second B }
Pair{1, "hello"} // ✗ compile error
Pair[int, string]{1, "hello"} // ✓
Useful Production Patterns
1. Map, Filter, Reduce
The functional programming trio is the classic generics use case:
// Map: transform every element
func Map[T, R any](slice []T, fn func(T) R) []R {
result := make([]R, len(slice))
for i, v := range slice {
result[i] = fn(v)
}
return result
}
// Filter: select elements satisfying a predicate
func Filter[T any](slice []T, pred func(T) bool) []T {
var result []T
for _, v := range slice {
if pred(v) {
result = append(result, v)
}
}
return result
}
// Reduce: accumulate values
func Reduce[T, R any](slice []T, initial R, fn func(R, T) R) R {
acc := initial
for _, v := range slice {
acc = fn(acc, v)
}
return acc
}
Real usage examples in a service layer:
orders := []Order{
{ID: 1, Amount: 150_000, Status: "paid"},
{ID: 2, Amount: 200_000, Status: "pending"},
{ID: 3, Amount: 75_000, Status: "paid"},
{ID: 4, Amount: 300_000, Status: "paid"},
}
// Only paid orders
paidOrders := Filter(orders, func(o Order) bool {
return o.Status == "paid"
})
// Take only the amounts
amounts := Map(paidOrders, func(o Order) int64 {
return o.Amount
})
// Total revenue
totalRevenue := Reduce(amounts, int64(0), func(acc, amount int64) int64 {
return acc + amount
})
// totalRevenue = 525_000
2. Result Types for Expressive Error Handling
type Result[T any] struct {
value T
err error
}
func Ok[T any](val T) Result[T] {
return Result[T]{value: val}
}
func Err[T any](err error) Result[T] {
return Result[T]{err: err}
}
func (r Result[T]) IsOk() bool { return r.err == nil }
func (r Result[T]) Unwrap() T {
if r.err != nil {
panic(fmt.Sprintf("called Unwrap on error Result: %v", r.err))
}
return r.value
}
func (r Result[T]) UnwrapOr(defaultVal T) T {
if r.err != nil {
return defaultVal
}
return r.value
}
func (r Result[T]) Error() error { return r.err }
Usage:
func FetchUser(id int) Result[User] {
user, err := db.FindUser(id)
if err != nil {
return Err[User](fmt.Errorf("user %d not found: %w", id, err))
}
return Ok(user)
}
result := FetchUser(42)
if result.IsOk() {
user := result.Unwrap()
fmt.Println(user.Name)
} else {
log.Error(result.Error())
}
3. Generic Caches
type Cache[K comparable, V any] struct {
mu sync.RWMutex
store map[K]V
ttl map[K]time.Time
dur time.Duration
}
func NewCache[K comparable, V any](dur time.Duration) *Cache[K, V] {
return &Cache[K, V]{
store: make(map[K]V),
ttl: make(map[K]time.Time),
dur: dur,
}
}
func (c *Cache[K, V]) Set(key K, val V) {
c.mu.Lock()
defer c.mu.Unlock()
c.store[key] = val
c.ttl[key] = time.Now().Add(c.dur)
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
exp, exists := c.ttl[key]
if !exists || time.Now().After(exp) {
var zero V
return zero, false
}
return c.store[key], true
}
Usage — one cache implementation for various types:
// Cache for users with an int key
userCache := NewCache[int, User](5 * time.Minute)
userCache.Set(42, User{ID: 42, Name: "Alice"})
user, ok := userCache.Get(42)
// Cache for sessions with a string key
sessionCache := NewCache[string, Session](30 * time.Minute)
sessionCache.Set("token-abc", Session{UserID: 1})
4. Generic Repository Interfaces
type Repository[T any, ID comparable] interface {
FindByID(ctx context.Context, id ID) (T, error)
FindAll(ctx context.Context) ([]T, error)
Save(ctx context.Context, entity T) error
Delete(ctx context.Context, id ID) error
}
// Base implementation with common CRUD operations
type BaseRepository[T any, ID comparable] struct {
db *sql.DB
tableName string
idField string
}
Each model can create a specific implementation without redefining the base contract:
type UserRepository struct {
BaseRepository[User, int]
}
type ProductRepository struct {
BaseRepository[Product, string]
}
5. Pipelines with Generics
type Pipeline[T any] struct {
steps []func(T) T
}
func NewPipeline[T any]() *Pipeline[T] {
return &Pipeline[T]{}
}
func (p *Pipeline[T]) Pipe(fn func(T) T) *Pipeline[T] {
p.steps = append(p.steps, fn)
return p
}
func (p *Pipeline[T]) Execute(input T) T {
result := input
for _, step := range p.steps {
result = step(result)
}
return result
}
Usage for text processing:
pipeline := NewPipeline[string]().
Pipe(strings.TrimSpace).
Pipe(strings.ToLower).
Pipe(func(s string) string { return strings.ReplaceAll(s, " ", "-") })
slug := pipeline.Execute(" Hello World ")
// slug = "hello-world"
Generic vs Interface: When to Use Each
This is the most frequently asked question. The answer isn’t always black and white, but there’s a fairly clear guideline:
| Situation | Use | Reason |
|---|---|---|
| Defining behavior to be implemented | Interface | Runtime polymorphism — different types can be treated the same |
| Working with data of various types | Generic | Compile-time type safety, no boxing overhead |
| Heterogeneous collections (different types in one slice) | Interface | A generic []T slice can only store one type |
| Algorithms independent of type | Generic | One implementation for all types satisfying the constraint |
| Dependency injection | Interface | Allows swapping implementations at runtime or in tests |
| Utility functions (Map, Filter, Sort) | Generic | Same logic, different data types |
// Interface — right for defining behavior
type PaymentGateway interface {
Charge(amount int64, currency string) (TransactionID, error)
Refund(txID TransactionID) error
}
// Various implementations can be used interchangeably
var gateway PaymentGateway
gateway = &StripeGateway{}
gateway = &MidtransGateway{}
gateway = &MockGateway{} // for testing
// Generic — right for algorithms working on data
func SortBy[T any](slice []T, less func(a, b T) bool) []T {
result := make([]T, len(slice))
copy(result, slice)
sort.Slice(result, func(i, j int) bool {
return less(result[i], result[j])
})
return result
}
// One function to sort various types
sortedUsers := SortBy(users, func(a, b User) bool { return a.Name < b.Name })
sortedOrders := SortBy(orders, func(a, b Order) bool { return a.Amount > b.Amount })
Generics Limitations in Go
Go makes deliberate design choices to limit certain things. Understanding these limitations prevents frustration:
1. Methods Can’t Have Their Own Type Parameters
type MySlice[T any] struct {
data []T
}
// ✗ NOT VALID in Go — methods can't have new type parameters
func (s MySlice[T]) Map[R any](fn func(T) R) []R {
// compile error
}
// ✓ Solution: use a top-level function, not a method
func MapSlice[T, R any](s MySlice[T], fn func(T) R) []R {
result := make([]R, len(s.data))
for i, v := range s.data {
result[i] = fn(v)
}
return result
}
This is a deliberate limitation to keep Go’s compiler simple. Many developers from Java or Kotlin find this limiting, but in practice the top-level function solution is often clearer.
2. No Specialization
In C++, you can provide special implementations for specific types. Go doesn’t support this:
// ✗ Can't create special versions for specific types
// No template specialization like C++
3. Type Switches Don’t Work on Type Parameters
func Process[T any](val T) string {
// ✗ This doesn't work as expected
switch v := any(val).(type) { // must cast to any first
case int:
return fmt.Sprintf("int: %d", v)
case string:
return fmt.Sprintf("string: %s", v)
}
return "unknown"
}
If you need a type switch, that’s a signal generics might not be the right solution for this case.
4. Can’t Instantiate Type Parameters
func NewSlice[T any](size int) []T {
return make([]T, size) // ✓ make works
}
func NewStruct[T any]() T {
return T{} // ✓ the zero value works
}
// ✗ Can't call a constructor that may not exist
func New[T any]() T {
return T.New() // compile error
}
To create instances with custom initialization, use a factory function as a parameter:
func NewSliceOf[T any](size int, factory func() T) []T {
result := make([]T, size)
for i := range result {
result[i] = factory()
}
return result
}
Generics Anti-Patterns to Avoid
1. Over-Generalization
// ANTI-PATTERN: generics for a function used only once
func AddInts[T ~int](a, b T) T {
return a + b
}
// You never call this with any type other than int
// CORRECT: just use a regular function
func AddInts(a, b int) int {
return a + b
}
Generics add cognitive complexity. If there’s no real benefit from type flexibility, don’t use generics.
2. Constraints Too Loose
// ANTI-PATTERN: using any but performing operations not supported by all types
func Double[T any](val T) T {
return val + val // ✗ compile error: operator + not defined for any
}
// CORRECT: the right constraint
type Addable interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~float32 | ~float64 | ~string
}
func Double[T Addable](val T) T {
return val + val // ✓
}
3. Replacing Interfaces with Generics for Dependency Injection
// ANTI-PATTERN: generics for something that should be an interface
type Service[DB any] struct {
db DB
}
func (s Service[DB]) GetUser(id int) User {
// how do you call DB methods? T = any, no contract
}
// CORRECT: use an interface to define the behavior contract
type Database interface {
QueryRow(query string, args ...any) *sql.Row
Exec(query string, args ...any) (sql.Result, error)
}
type Service struct {
db Database
}
4. Generics to Avoid Writing Concrete Types
// ANTI-PATTERN: generics just to avoid writing out types
func GetConfig[T any](key string) T {
// an implementation doing type assertions inside
val := config[key]
return val.(T) // runtime type assertion — this isn't type safety!
}
// This doesn't provide the compile-time safety generics promise
// CORRECT: define specific accessors or use a clear pattern
func GetStringConfig(key string) (string, error) { ... }
func GetIntConfig(key string) (int, error) { ... }
Diagram: When to Choose Generic, Interface, or a Concrete Type
flowchart TD
A{Same logic for<br/>different types?} -- No --> B[Use a concrete type]
A -- Yes --> C{Need a heterogeneous<br/>collection?}
C -- Yes --> D[Use an Interface]
C -- No --> E{Defining<br/>behavior?}
E -- Yes --> D
E -- No --> F{Need compile-time<br/>type safety?}
F -- Yes --> G[Use Generics]
F -- No --> H{Critical<br/>performance?}
H -- Yes --> G
H -- No --> DSummary
- Two problems generics solve: per-type code duplication and the loss of type safety from
interface{}/any— generics provide a solution combining the strengths of both.- Basic syntax:
func Name[T Constraint](param T) T— square brackets after the function name for the type parameter, followed by the constraint.- The tilde
~is key:~intacceptsintand all custom types whose underlying type isint— almost always use~for primitive type constraints.comparableis a built-in constraint for types usable as map keys (==and!=— useful for functions likeContains,IndexOf, and all map-based structures.- Type inference works from function arguments — you almost never need to write type arguments explicitly except for return-only type parameters.
- Methods can’t have new type parameters — this is a deliberate limitation; use top-level functions instead.
- Generic vs Interface: use interfaces to define behavior (runtime polymorphism, dependency injection), use generics for algorithms and data (Map, Filter, caches, collection utilities).
- The most useful production patterns: Map/Filter/Reduce,
Optional[T],Result[T], generic caches with TTLs, andResponse[T]as an API response wrapper.- Main anti-patterns: over-generalizing functions used only once, constraints too loose, using generics as a substitute for interfaces in dependency injection, and doing type assertions inside generic functions.