Why Generics in Golang Is a Feature Every Engineer Must Master
17 min read

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 works
  • any — the constraint. any is an alias for interface{} — meaning any type is accepted
  • val T — a function parameter of type T
  • T (after the closing bracket) — the return type, also of type T

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:

SituationUseReason
Defining behavior to be implementedInterfaceRuntime polymorphism — different types can be treated the same
Working with data of various typesGenericCompile-time type safety, no boxing overhead
Heterogeneous collections (different types in one slice)InterfaceA generic []T slice can only store one type
Algorithms independent of typeGenericOne implementation for all types satisfying the constraint
Dependency injectionInterfaceAllows swapping implementations at runtime or in tests
Utility functions (Map, Filter, Sort)GenericSame 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 --> D

Summary

  • 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: ~int accepts int and all custom types whose underlying type is int — almost always use ~ for primitive type constraints.
  • comparable is a built-in constraint for types usable as map keys (== and != — useful for functions like Contains, 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, and Response[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.

Portfolio