Why Generics Matter

Updated

September 13, 2026

Why Generics Matter

Generics exist so you can write one helper for many types without copy-paste and without any plus a type assertion. The boring default is still a concrete function. Reach for a type parameter when you have already written the same logic twice.

Mental model

A type parameter is a name for a type the caller fills in. min[T] is not “min of anything.” It is “min of one type T, chosen at the call site.” The compiler substitutes int, float64, or Cents and type-checks the body against a constraint — the set of types T is allowed to be.

Before Go 1.18, the desk either duplicated minInt / minFloat64 or hid the type behind interface{} and hoped. Duplication is honest but rot. any is one function that can panic at 5pm. Type parameters keep the honesty and drop the rot.

If every call uses the same type, you do not need a type parameter.

Worked examples

Case 1: The pre-generic pain

The desk tracks table counts (int) and prices in cents (int64). Two “smaller of these two” helpers. Same body. Two names. Two tests. Tomorrow someone adds minFloat for a tax rate.

Save as dup_min.go:

// dup_min.go
package main

import "fmt"

func minInt(a, b int) int {
    if a < b {
        return a
    }
    return b
}

func minInt64(a, b int64) int64 {
    if a < b {
        return a
    }
    return b
}

func main() {
    tables := minInt(4, 2)
    price := minInt64(1250, 990)
    fmt.Printf("tables %d, price %d cents\n", tables, price)
}

Run:

go run dup_min.go

Output:

tables 2, price 990 cents

The < is identical. The types are not interchangeable, so the compiler will not let you share the function. That is the pain: not “I wish I had a framework,” but “I am about to paste a third copy.”

Case 2: One generic function

cmp.Ordered is the standard-library constraint for types that support <. One function, two call sites, still statically typed.

Save as generic_min.go:

// generic_min.go
package main

import (
    "cmp"
    "fmt"
)

func min[T cmp.Ordered](a, b T) T {
    if a < b {
        return a
    }
    return b
}

func main() {
    tables := min(4, 2)
    price := min(int64(1250), int64(990))
    fmt.Printf("tables %d, price %d cents\n", tables, price)
}

Run:

go run generic_min.go

Output:

tables 2, price 990 cents

min(4, 2) infers T as int. min(int64(1250), int64(990)) infers int64. Mixing min(4, int64(990)) does not compile: there is no single T. That is a feature.

The standard library already has min and max built-ins for ordered values, and slices.Min / slices.Max for slices. You would not ship min as a helper at work. You would ship a generic when the duplicated logic is yours — clamping a desk value, picking the cheaper of two quotes, merging two sorted ticket lists.

Case 3: Slice helpers copy-pasted

Same story with slices. A “first matching table” helper for []int and a “first matching name” helper for []string will drift. One type parameter, one loop.

Save as first.go:

// first.go
package main

import "fmt"

func first[T comparable](list []T, want T) (T, bool) {
    var zero T
    for _, v := range list {
        if v == want {
            return v, true
        }
    }
    return zero, false
}

func main() {
    tables := []int{3, 7, 12}
    names := []string{"Amina", "Bo", "Chen"}

    t, ok := first(tables, 7)
    fmt.Printf("table %d found=%t\n", t, ok)

    n, ok := first(names, "Dee")
    fmt.Printf("name %q found=%t\n", n, ok)
}

Run:

go run first.go

Output:

table 7 found=true
name "" found=false

A miss returns the zero value of T ("" for string) and false. comparable is the constraint for types that support ==. Maps, slices, and functions are not comparable, so first([][]int{...}, ...) does not compile. The constraint is the documentation.

At work, prefer slices.Contains and slices.Index over writing first yourself. The next chapter does that. This chapter is the reason those functions can exist once.

Case 4: When not to use generics

This function only ever totals ticket prices in cents. There is one type. A type parameter would be a lie you tell the next reader: “this might be anything.”

Save as total.go:

// total.go
package main

import "fmt"

func totalCents(prices []int) int {
    sum := 0
    for _, p := range prices {
        sum += p
    }
    return sum
}

func main() {
    fmt.Println(totalCents([]int{450, 800, 250}))
}

Run:

go run total.go

Output:

1500

Do not write func total[T ~int](prices []T) T until a second integer-like type actually shows up. One concrete type is enough.

The trap

A generic that exists to look generic. This program wraps fmt.Println in a type parameter. Every call still prints one value. The type parameter does no work.

Save as too_generic.go:

// too_generic.go
package main

import "fmt"

func announce[T any](v T) {
    fmt.Println(v)
}

func main() {
    announce("desk is open")
    announce(12)
}

Run:

go run too_generic.go

Output:

desk is open
12

fmt.Println already accepts any values. The boring version is fmt.Println(v). A type parameter has to remove duplication or preserve a concrete type the caller needs back. If it does neither, delete it.

The boring rule

  • Duplicate once. Genericize when the second copy appears, not before.
  • Constrain T. any is last resort, not the default.
  • If every call site uses one type, write that type.
  • Prefer a standard-library generic (slices.*, cmp.*, maps.*) over a house helper.
  • Generics do not replace interfaces. Interfaces are for behavior. Type parameters are for containers and algorithms that must keep the element type.

Try this

  1. In dup_min.go, add minFloat64. Feel the paste. Then delete all three and call the built-in min instead.
  2. In generic_min.go, try min(4, int64(990)). Read the compiler error. Fix it by converting one argument.
  3. In total.go, do not add a type parameter. Add a discount int argument instead and subtract it from the sum, clamped at zero with the built-in max.