Alright, let’s get our hands dirty. You’ve probably written a dozen functions that do the exact same thing, just for different types. You copy, paste, change int to string, and die a little inside. We’ve all been there. Generics in Go, specifically the T any part you see here, are our long-awaited pardon from that particular flavor of tedium.

The syntax func F[T any](x T) T might look a bit alien at first, but break it down. Before the function name F, we declare our type parameters in square brackets. [T any] is the simplest form: we’re saying “For this function, we’re going to use a type we’ll call T. The any part is its constraint, meaning T can literally be… any type.” It’s the equivalent of a wildcard. Inside the function body, x is of that type T, and the function also returns a value of type T.

Let’s write something actually useful instead of F.

// Without generics: the dark times.
func ReverseInts(s []int) []int {
    result := make([]int, len(s))
    for i, j := 0, len(s)-1; i < len(s); i, j = i+1, j-1 {
        result[i] = s[j]
    }
    return result
}

func ReverseStrings(s []string) []string {
    result := make([]string, len(s))
    for i, j := 0, len(s)-1; i < len(s); i, j = i+1, j-1 {
        result[i] = s[j]
    }
    return result
}

// With generics: enlightenment.
func Reverse[T any](s []T) []T {
    result := make([]T, len(s))
    for i, j := 0, len(s)-1; i < len(s); i, j = i+1, j-1 {
        result[i] = s[j]
    }
    return result
}

func main() {
    ints := []int{1, 2, 3, 4}
    strs := []string{"a", "b", "c", "d"}
    fmt.Println(Reverse(ints))  // [4 3 2 1]
    fmt.Println(Reverse(strs))  // [d c b a]
}

See? One function to rule them all. The compiler generates the specific Reverse[int] and Reverse[string] code for you at compile time. You write the logic once.

The any Constraint is a Lie (Sort Of)

Here’s the first “gotcha.” any isn’t magic. It’s just a predefined type constraint that is, literally, an alias for interface{}. I know, I know. After years of being told to avoid interface{}, it’s now the foundation of our new generic paradise. The irony is not lost on me. This means inside func F[T any](x T) T, you can only do with x what you can do with an interface{}. You can pass it around, store it, return it. But you cannot call methods on it, compare it, or use operators on it.

This code will explode in your face:

func Add[T any](a, b T) T {
    return a + b // Invalid operation: a + b (operator + not defined on T)
}

The compiler has no idea if T will be a string, an int, or a database/sql/driver.Rows—so it assumes the worst and stops you. any is the constraint you use when you’re just moving boxes without caring what’s inside them.

Type Inference: Letting the Compiler Do the Work

You might have noticed we called Reverse(ints), not Reverse[int](ints). This isn’t a typo. The Go compiler is smart enough to perform type inference. It looks at the arguments you’re passing and deduces that T must be int in this case. It’s a fantastic quality-of-life feature. You only need to explicitly specify the type parameter if the compiler can’t figure it out from the arguments, which usually happens when the type parameter isn’t used in the input.

func PrintType[T any]() {
    fmt.Printf("Type is: %T\n", *new(T)) // A cheeky way to get a value of type T to print
}

func main() {
    PrintType()        // Compiler error: cannot infer T
    PrintType[int]()   // This works: "Type is: int"
    PrintType[string]() // This works: "Type is: string"
}

The Zero Value Problem

This is a classic pitfall. How do you get the zero value for a generic type T? You might try nil, but that only works for slices, pointers, maps, channels, and interfaces. What if T is an int? nil is not a valid value.

The correct, idiomatic way is to use a var declaration.

func ZeroValue[T any]() T {
    var zero T
    return zero
}

func main() {
    fmt.Println(ZeroValue[int]())    // 0
    fmt.Println(ZeroValue[string]()) // "" (empty string)
    fmt.Println(ZeroValue[*int]())   // <nil>
}

The line var zero T is the key. It will always give you the proper zero value for whatever type T is instantiated with. It’s clean, readable, and the official way to do it.

Concrete Types vs. Type Parameters

It’s crucial to internalize the difference. A type parameter like T is not a type itself; it’s a placeholder for a type. This becomes critical when dealing with return types or composite literals.

func MakeSlice[T any](lenAndCap int) []T {
    return make([]T, lenAndCap) // This is fine. We're making a slice OF T.
}

func HoldsT[T any](value T) T {
    // This is a compile error: "cannot use type T outside a type constraint"
    var x T.T // You can't use T as a selector like this.
    return value
}

The first example works because the whole expression []T is a type derived from the type parameter. The second fails because you’re trying to treat T itself as a named type that might have methods, which it isn’t.