For most of its life, Go didn’t have generics — the ability to write a function or type that works with many types without giving up type safety. To handle “any type”, developers used interface{} (now any), which works but loses type information at compile time.

Go 1.18 (released in 2022) added generics. They’re still a relatively new tool, used carefully and sparingly in the Go community. This lesson teaches you what they are, how to use them, and when not to.

The problem generics solve

Imagine you want a function that returns the largest element in a slice:

func MaxInt(s []int) int {
    m := s[0]
    for _, v := range s {
        if v > m {
            m = v
        }
    }
    return m
}

That works for []int. What about []float64? You’d write a near-identical MaxFloat64. And []string? Another copy. The logic is the same; only the type changes.

Before generics, the workaround was to take an []any, run runtime type assertions, and hope nothing crashed. Slow, error-prone, ugly.

Generics let you write the function once, for any type.

Your first generic function

package main

import "fmt"

func Max[T int | float64 | string](s []T) T {
    m := s[0]
    for _, v := range s {
        if v > m {
            m = v
        }
    }
    return m
}

func main() {
    fmt.Println(Max([]int{3, 1, 4, 1, 5, 9, 2, 6}))
    fmt.Println(Max([]float64{1.5, 0.7, 9.1}))
    fmt.Println(Max([]string{"banana", "apple", "cherry"}))
}
9
9.1
cherry

Reading the new syntax:

  • [T int | float64 | string] — the type parameter list. T is a name for “the type the caller will use.” The int | float64 | string is the constraint — what types are allowed.
  • (s []T) T — a normal parameter list, but it uses T instead of a concrete type
  • The function body works exactly like a regular function

When you call Max([]int{...}), Go figures out T = int and instantiates the function for that type — checked at compile time, fast at runtime, completely type-safe.

Constraints from golang.org/x/exp/constraints and cmp

Listing types manually (int | float64 | ...) is fine for one-off functions. For common cases, the standard library provides ready-made constraints. The main one you’ll use is cmp.Ordered, which covers any type that supports <, >, ==:

package main

import (
    "cmp"
    "fmt"
)

func Max[T cmp.Ordered](s []T) T {
    m := s[0]
    for _, v := range s {
        if v > m {
            m = v
        }
    }
    return m
}

func main() {
    fmt.Println(Max([]int{3, 1, 4}))
    fmt.Println(Max([]string{"a", "z", "m"}))
}
4
z

cmp.Ordered means “any type whose values can be compared with <, >, <=, >=.” That covers all integer types, all float types, and strings — exactly what you want for Max. (Complex numbers aren’t ordered, so they’re excluded.)

The comparable constraint

There’s a built-in constraint called comparable for types that support == and !=. Useful for things like deduplication:

package main

import "fmt"

func Unique[T comparable](s []T) []T {
    seen := make(map[T]struct{})
    result := []T{}
    for _, v := range s {
        if _, ok := seen[v]; !ok {
            seen[v] = struct{}{}
            result = append(result, v)
        }
    }
    return result
}

func main() {
    fmt.Println(Unique([]int{1, 2, 1, 3, 2, 4}))
    fmt.Println(Unique([]string{"a", "b", "a", "c"}))
}
[1 2 3 4]
[a b c]

comparable is required because we’re using T as a map key — and map keys must be comparable.

struct{}{} (an empty anonymous struct) is the Go idiom for “I want a set, not a real value-mapping.” It takes zero memory.

Generic types

You can also define generic types — most often containers like stacks, queues, sets, or trees.

package main

import "fmt"

type Stack[T any] struct {
    items []T
}

func (s *Stack[T]) Push(v T) {
    s.items = append(s.items, v)
}

func (s *Stack[T]) Pop() (T, bool) {
    var zero T
    if len(s.items) == 0 {
        return zero, false
    }
    last := len(s.items) - 1
    v := s.items[last]
    s.items = s.items[:last]
    return v, true
}

func main() {
    s := &Stack[int]{}
    s.Push(1)
    s.Push(2)
    s.Push(3)

    for {
        v, ok := s.Pop()
        if !ok {
            break
        }
        fmt.Println(v)
    }
}
3
2
1

Stack[int] is a stack of ints. Stack[string] would be a stack of strings. The type is checked at compile time — you can’t push a string into a Stack[int].

The var zero T trick gives you the zero value of whatever type T is — 0 for ints, "" for strings, nil for pointers, etc. It’s the generic way to say “no value yet.”

When to use generics — and when not to

Generics are tempting. They’re also a footgun. Here are some rules of thumb the Go community has settled on:

Good fits for generics:

  • Container types — stack, queue, set, tree
  • Functions on collections — Map, Filter, Reduce
  • Numeric helpers — Max, Min, Sum, Clamp
  • Generic comparison helpers

Bad fits for generics:

  • “Just in case” — don’t make a function generic before there’s a concrete second type that needs it
  • When an interface fits — io.Reader works for any reader without generics
  • When the bodies for different types would actually be different — generics force you to write one body for all of them

A famous line from the Go team: “If you’re not sure whether to use generics, don’t.”

Generics vs interfaces — when each wins

Both let you write code that handles many types. The difference:

  • Interfaces describe behavior (“anything with a Write method”)
  • Generics describe structure (“any type that’s a number”)

If you’d be calling methods on the value, use an interface. If you’d be doing operations like +, <, or storing in a slice, use generics.

Some functions need both. That’s allowed — interfaces can themselves be used as generic constraints.

Course wrap-up

You’ve completed Golang Programming - Fundamentals. To recap what you can now do:

  • Write Go programs from scratch with proper structure
  • Use the language fundamentals — variables, types, control flow, loops
  • Model data with slices, maps, structs, and pointers
  • Build reusable code with functions, methods, and interfaces
  • Handle errors the Go way and organize code into packages and modules
  • Write concurrent programs with goroutines, channels, and synchronization primitives
  • Test, benchmark, and document your code
  • Reach for reflection and generics when (and only when) you need them

Where to go from here:

  • Build something. A CLI tool, a web server, a Discord bot. Real practice cements these concepts.
  • Read real Go code. Browse the standard library on pkg.go.dev/std, or open-source Go projects on GitHub.
  • The official tour. tour.golang.org is a fantastic interactive walk through the language.
  • Effective Go. go.dev/doc/effective_go — a short, opinionated guide to writing idiomatic Go.

Thanks for going through the course. Welcome to Go.

Toggle theme (T)