Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Generics in Go

Go 1.18 introduced generics (type parameters), one of the most requested features in Go’s history. Generics allow you to write functions and types that work with any data type while maintaining full type safety at compile time. Before generics, Go developers faced an uncomfortable choice: duplicate code for each type (error-prone), or use interface{} and lose type safety (runtime panics). Generics solve this by letting you write the code once with a type placeholder, and the compiler fills in the concrete type at each call site — catching type mismatches at compile time, not in production at 3 AM. Think of generics like a cookie cutter that works with any dough. The cutter (your generic function) defines the shape of the operation, but the dough (the type) can be anything that fits the constraints. Without generics, you needed a separate cutter for each dough type, or you used a “universal” cutter that had no idea what shape it was making (interface{}).

Why Generics?

Before generics, you had two options for writing reusable code:
  1. Use interface{}: Lose type safety and require type assertions
  2. Code generation: Maintain multiple copies of similar code
// Before generics: Using interface{}
func MinInterface(a, b interface{}) interface{} {
    // No type safety, runtime panics possible
    switch a := a.(type) {
    case int:
        if a < b.(int) {
            return a
        }
        return b
    case float64:
        if a < b.(float64) {
            return a
        }
        return b
    default:
        panic("unsupported type")
    }
}

// With generics: Type-safe and clean
func Min[T constraints.Ordered](a, b T) T {
    if a < b {
        return a
    }
    return b
}

Type Parameters

Type parameters are declared in square brackets before the function parameters.

Basic Syntax

func FunctionName[T TypeConstraint](params) returnType {
    // function body
}

Simple Generic Function

package main

import "fmt"

// Print any slice
func PrintSlice[T any](s []T) {
    for _, v := range s {
        fmt.Printf("%v ", v)
    }
    fmt.Println()
}

func main() {
    PrintSlice([]int{1, 2, 3})           // 1 2 3
    PrintSlice([]string{"a", "b", "c"})  // a b c
    PrintSlice([]float64{1.1, 2.2, 3.3}) // 1.1 2.2 3.3
}

Multiple Type Parameters

func Map[T, U any](s []T, f func(T) U) []U {
    result := make([]U, len(s))
    for i, v := range s {
        result[i] = f(v)
    }
    return result
}

// Usage
nums := []int{1, 2, 3, 4}
strings := Map(nums, func(n int) string {
    return fmt.Sprintf("num:%d", n)
})
// strings = ["num:1", "num:2", "num:3", "num:4"]

Type Constraints

Constraints define what operations can be performed on type parameters.

The any Constraint

any is an alias for interface{} - allows any type.
func Identity[T any](v T) T {
    return v
}

The comparable Constraint

comparable allows types that support == and != operators.
func Contains[T comparable](slice []T, target T) bool {
    for _, v := range slice {
        if v == target {
            return true
        }
    }
    return false
}

// Usage
fmt.Println(Contains([]int{1, 2, 3}, 2))       // true
fmt.Println(Contains([]string{"a", "b"}, "c")) // false

The constraints Package

The golang.org/x/exp/constraints package provides common constraints:
import "golang.org/x/exp/constraints"

// Ordered: types that support <, <=, >, >=
func Max[T constraints.Ordered](a, b T) T {
    if a > b {
        return a
    }
    return b
}

// Integer: all integer types (int, int8, int16, int32, int64, uint, etc.)
func Sum[T constraints.Integer](nums []T) T {
    var sum T
    for _, n := range nums {
        sum += n
    }
    return sum
}

// Float: float32 and float64
func Average[T constraints.Float](nums []T) T {
    if len(nums) == 0 {
        return 0
    }
    var sum T
    for _, n := range nums {
        sum += n
    }
    return sum / T(len(nums))
}

Custom Constraints

Define your own constraints using interface syntax:
// Union constraint: allows specific types
type Number interface {
    int | int32 | int64 | float32 | float64
}

func Double[T Number](n T) T {
    return n * 2
}

// Constraint with methods
type Stringer interface {
    String() string
}

func PrintAll[T Stringer](items []T) {
    for _, item := range items {
        fmt.Println(item.String())
    }
}

// Combining constraints
type OrderedStringer interface {
    constraints.Ordered
    fmt.Stringer
}

Underlying Type Constraint (~)

The ~ operator matches types with the same underlying type. This is crucial for real-world code where teams define domain types like type UserID int64 or type Celsius float64. Without ~, those custom types would not satisfy generic constraints even though they behave identically to their underlying type.
type MyInt int

// Without ~: only accepts the exact type int
type IntOnly interface {
    int
}

// With ~: accepts int and any type with underlying type int (like MyInt)
type IntLike interface {
    ~int
}

func Triple[T IntLike](n T) T {
    return n * 3
}

func main() {
    var x MyInt = 5
    fmt.Println(Triple(x)) // Works! Output: 15
}
Pitfall — Forgetting ~ in Constraints: If you define a constraint as int | float64 without the tilde, custom types like type Score int will not satisfy it. This is the most common source of “does not satisfy” errors when adopting generics. Always use ~int | ~float64 unless you deliberately want to exclude derived types.

Generic Types

You can also define generic structs, interfaces, and type aliases.

Generic Structs

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

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

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

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

func (s *Stack[T]) Len() int {
    return len(s.items)
}

// Usage
intStack := &Stack[int]{}
intStack.Push(1)
intStack.Push(2)
val, _ := intStack.Pop() // val = 2

stringStack := &Stack[string]{}
stringStack.Push("hello")
stringStack.Push("world")

Generic Linked List

type Node[T any] struct {
    Value T
    Next  *Node[T]
}

type LinkedList[T any] struct {
    Head *Node[T]
    Tail *Node[T]
    len  int
}

func (l *LinkedList[T]) Append(value T) {
    node := &Node[T]{Value: value}
    if l.Head == nil {
        l.Head = node
        l.Tail = node
    } else {
        l.Tail.Next = node
        l.Tail = node
    }
    l.len++
}

func (l *LinkedList[T]) ToSlice() []T {
    result := make([]T, 0, l.len)
    for current := l.Head; current != nil; current = current.Next {
        result = append(result, current.Value)
    }
    return result
}

Generic Map/Dictionary

type Pair[K comparable, V any] struct {
    Key   K
    Value V
}

type OrderedMap[K comparable, V any] struct {
    pairs []Pair[K, V]
    index map[K]int
}

func NewOrderedMap[K comparable, V any]() *OrderedMap[K, V] {
    return &OrderedMap[K, V]{
        pairs: make([]Pair[K, V], 0),
        index: make(map[K]int),
    }
}

func (m *OrderedMap[K, V]) Set(key K, value V) {
    if idx, exists := m.index[key]; exists {
        m.pairs[idx].Value = value
        return
    }
    m.index[key] = len(m.pairs)
    m.pairs = append(m.pairs, Pair[K, V]{Key: key, Value: value})
}

func (m *OrderedMap[K, V]) Get(key K) (V, bool) {
    if idx, exists := m.index[key]; exists {
        return m.pairs[idx].Value, true
    }
    var zero V
    return zero, false
}

func (m *OrderedMap[K, V]) Keys() []K {
    keys := make([]K, len(m.pairs))
    for i, p := range m.pairs {
        keys[i] = p.Key
    }
    return keys
}

Common Generic Patterns

Filter, Map, Reduce

package collections

// Filter returns elements that satisfy the predicate
func Filter[T any](slice []T, predicate func(T) bool) []T {
    result := make([]T, 0)
    for _, v := range slice {
        if predicate(v) {
            result = append(result, v)
        }
    }
    return result
}

// Map transforms each element
func Map[T, U any](slice []T, transform func(T) U) []U {
    result := make([]U, len(slice))
    for i, v := range slice {
        result[i] = transform(v)
    }
    return result
}

// Reduce combines all elements into a single value
func Reduce[T, U any](slice []T, initial U, reducer func(U, T) U) U {
    result := initial
    for _, v := range slice {
        result = reducer(result, v)
    }
    return result
}

// Usage
nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}

evens := Filter(nums, func(n int) bool { return n%2 == 0 })
// [2, 4, 6, 8, 10]

doubled := Map(nums, func(n int) int { return n * 2 })
// [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

sum := Reduce(nums, 0, func(acc, n int) int { return acc + n })
// 55

Result Type (Error Handling)

type Result[T any] struct {
    value T
    err   error
}

func Ok[T any](value T) Result[T] {
    return Result[T]{value: value}
}

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]) IsErr() bool {
    return r.err != nil
}

func (r Result[T]) Unwrap() T {
    if r.err != nil {
        panic(r.err)
    }
    return r.value
}

func (r Result[T]) UnwrapOr(defaultValue T) T {
    if r.err != nil {
        return defaultValue
    }
    return r.value
}

func (r Result[T]) Map(f func(T) T) Result[T] {
    if r.err != nil {
        return r
    }
    return Ok(f(r.value))
}

// Usage
func divide(a, b int) Result[int] {
    if b == 0 {
        return Err[int](errors.New("division by zero"))
    }
    return Ok(a / b)
}

result := divide(10, 2)
if result.IsOk() {
    fmt.Println(result.Unwrap()) // 5
}

Optional Type

type Optional[T any] struct {
    value   T
    present bool
}

func Some[T any](value T) Optional[T] {
    return Optional[T]{value: value, present: true}
}

func None[T any]() Optional[T] {
    return Optional[T]{present: false}
}

func (o Optional[T]) IsSome() bool {
    return o.present
}

func (o Optional[T]) IsNone() bool {
    return !o.present
}

func (o Optional[T]) Get() (T, bool) {
    return o.value, o.present
}

func (o Optional[T]) OrElse(defaultValue T) T {
    if o.present {
        return o.value
    }
    return defaultValue
}

// Usage
func findUser(id int) Optional[User] {
    user, found := db.GetUser(id)
    if !found {
        return None[User]()
    }
    return Some(user)
}

Type Inference

Go can often infer type parameters from function arguments:
func First[T any](slice []T) (T, bool) {
    if len(slice) == 0 {
        var zero T
        return zero, false
    }
    return slice[0], true
}

// Type inference - no need to specify [int]
val, ok := First([]int{1, 2, 3})

// Explicit type parameter (sometimes needed)
val, ok := First[int]([]int{1, 2, 3})

Best Practices

When to Use Generics

Good use cases:
  • Collection types (stacks, queues, trees)
  • Utility functions (filter, map, reduce)
  • Type-safe containers
  • Algorithms that work on multiple types
Avoid generics when:
  • A single concrete type works fine
  • Interfaces provide sufficient abstraction
  • It makes the code harder to read
Pitfall — Generics Everywhere: Coming from languages like Rust or TypeScript, it is tempting to make everything generic. Go’s culture values simplicity over abstraction. Write concrete code first. Only introduce generics when you find yourself duplicating the same logic for multiple types. As the Go team says: “write code, not types.”

Keep Constraints Simple

// ❌ Overly complex constraint
type WeirdConstraint interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
    ~float32 | ~float64
}

// ✅ Use standard constraints
import "golang.org/x/exp/constraints"
type Number constraints.Integer | constraints.Float

Zero Values in Generics

Getting the zero value of a generic type is a pattern you will use constantly. The var zero T idiom is the standard approach:
func GetOrDefault[T any](slice []T, index int, defaultVal T) T {
    if index < 0 || index >= len(slice) {
        return defaultVal
    }
    return slice[index]
}

// Idiomatic way to create a zero value for any type
func Zero[T any]() T {
    var zero T // The zero value of T, whatever T is
    return zero
}
Pitfall — Zero Value Semantics in Generic Types: The zero value of a generic type might not be what you expect. For pointer types, var zero T is nil. For structs, it is a struct with all fields zeroed. If your generic code returns a zero value to signal “not found,” callers cannot easily distinguish “not found” from “found the zero value.” This is why Go’s idiomatic approach uses the (value, bool) pattern:
// WRONG: zero value is ambiguous
func Find[T comparable](slice []T, target T) T {
    for _, v := range slice {
        if v == target {
            return v
        }
    }
    var zero T
    return zero // Is this "not found" or "found the zero value"?
}

// RIGHT: use comma-ok pattern
func Find[T comparable](slice []T, target T) (T, bool) {
    for _, v := range slice {
        if v == target {
            return v, true
        }
    }
    var zero T
    return zero, false // Unambiguous: not found
}

Interview Questions

Generics allow writing functions and types that work with any data type while maintaining type safety. They were added in Go 1.18 to:
  • Reduce code duplication
  • Eliminate the need for type assertions with interface{}
  • Enable type-safe container types
  • Improve code reusability without sacrificing performance
  • any is an alias for interface{} and allows any type
  • comparable restricts to types that support == and != operators
  • Use comparable when you need to compare values (e.g., map keys, finding elements)
The ~ operator matches types with the same underlying type. For example, ~int matches int and any custom type like type MyInt int. Without ~, only the exact type matches.
No, Go does not support type parameters on methods. You can only have type parameters on the type itself or on standalone functions. This is a deliberate design decision to keep the language simple. If you need a method-like operation with an additional type parameter, extract it into a standalone generic function that takes the receiver as a parameter.
Go uses a hybrid approach called “GC shape stenciling.” Types with the same GC shape (e.g., all pointer types share one shape) share a single compiled version of the generic function, with a dictionary passed at runtime to handle type-specific operations. This means generics do not cause the binary bloat you might see in C++ templates, but there can be a small runtime cost from dictionary lookups compared to hand-written concrete code. In practice, the overhead is negligible for most applications.

Summary

ConceptDescription
Type ParametersDeclared in [T Constraint] before function params
anyAllows any type (alias for interface{})
comparableTypes supporting == and !=
constraints.OrderedTypes supporting <, >, <=, >=
~TMatches types with underlying type T
Generic StructsStructs with type parameters
Type InferenceGo often infers type parameters automatically

Interview Deep-Dive

Strong Answer:
  • The argument against generics was rooted in Go’s core design philosophy: simplicity. The Go team believed that generics would add significant complexity to the language spec, the compiler, error messages, and learning curve. They pointed to C++ templates (which produce notoriously cryptic error messages), Java generics (with type erasure creating confusing behavior), and argued that Go’s existing tools — interfaces and code generation — handled most use cases well enough.
  • What changed was a concrete proposal (by Ian Lance Taylor) that demonstrated generics could be added with minimal complexity. The constraints-based approach using interfaces (rather than C++-style concepts or Java-style bounded type parameters) fit naturally into Go’s existing type system. An interface already defines what operations a type supports, so using interfaces as constraints was a natural extension.
  • The practical pressure came from two pain points: the massive duplication in the standard library (separate sort.Ints, sort.Float64s, sort.Strings functions doing the same thing for different types) and the proliferation of interface{} in container libraries, which pushed type checking to runtime. The sync.Pool, for example, requires type assertions on every Get() call, which is both verbose and a source of runtime panics.
  • Go’s generics are deliberately limited compared to other languages. No generic methods (only generic functions and types), no specialization, no higher-kinded types. This keeps the implementation simple and error messages readable.
Follow-up: What is the ~ (tilde) operator in type constraints, and why was it necessary?Without ~, a constraint like interface{ int } only matches the exact type int. But in Go, you can define type UserID int — a named type with int as its underlying type. Without ~, a generic function constrained to int would not accept UserID, defeating much of the purpose. The ~int constraint matches any type whose underlying type is int, including UserID, type OrderID int, etc. This is essential for practical generics because Go codebases heavily use named types for domain modeling and type safety. Without ~, you would either need to convert every named type to its underlying type before calling generic functions, or the constraint system would be too restrictive to be useful.
Strong Answer:
  • Use interfaces when behavior (methods) is what matters, and the specific type is irrelevant. Use generics when the type itself matters — when you need to preserve type identity through the operation, work with the type’s operators (comparison, arithmetic), or avoid the overhead of interface boxing/unboxing.
  • Interfaces are better for: dependency injection (your service accepts a Repository interface, not a concrete type), polymorphic behavior (multiple types need to Render() differently), and API boundaries where the consumer defines what methods it needs. Example: a logging function that accepts io.Writer — it does not care whether it is writing to a file, buffer, or network connection. The behavior is what matters.
  • Generics are better for: data structures (a Stack[T] that preserves the element type so you do not need type assertions on every pop), utility functions operating on slices/maps (Filter[T], Map[T, U], Contains[T comparable]), and algorithms where the type must support specific operators (Min[T constraints.Ordered]). Without generics, you would either duplicate these for every type or use interface{} with runtime type assertions.
  • A concrete example where generics win: a Cache[K comparable, V any] type. With interfaces, every Get() returns interface{} requiring a type assertion. With generics, Get(key K) (V, bool) returns the actual type, and the compiler catches type mismatches at compile time.
  • The Go team’s guidance: “write code, not types.” Start with concrete types, move to interfaces when you need polymorphism, and only reach for generics when you find yourself duplicating the same logic for multiple types.
Follow-up: Why can you not add type parameters to methods in Go? What is the workaround?Go deliberately prohibits type parameters on methods (you can only have them on functions and type definitions). The reason is that method sets determine interface satisfaction, and if methods could have type parameters, the compiler could not determine at compile time whether a type satisfies an interface — it would depend on what type arguments the caller provides. This would break Go’s simple, compile-time interface checking. The workaround is to use generic functions instead of generic methods, or to put the type parameter on the struct itself. For example, instead of func (c *Cache) Get[V any](key string) V, you define type Cache[V any] struct{...} and then func (c *Cache[V]) Get(key string) V. If you truly need a method that operates on a type unknown to the parent struct, extract it into a package-level generic function that takes the struct as a parameter.
Strong Answer:
  • A generic Result type would be type Result[T any] struct { value T; err error } with constructors Ok[T](v T) Result[T] and Err[T](err error) Result[T], plus methods like Unwrap() T (panics on error), UnwrapOr(default T) T, and Map(fn func(T) T) Result[T] for chaining. This is inspired by Rust’s Result type.
  • Advantages: it makes error handling composable. You can chain operations with Map and FlatMap without nested if err != nil blocks. It makes impossible states unrepresentable — a Result is either Ok or Err, never both or neither. It works well for functional-style pipelines.
  • Trade-offs against Go’s standard pattern: it is not idiomatic. The entire Go ecosystem (standard library, third-party packages, tooling) expects (value, error) returns. A Result type creates friction with every library call — you need wrapper functions to convert between (T, error) and Result[T]. It hides the error handling — one of Go’s explicit design goals is that error handling is visible at every call site. Linters like errcheck cannot analyze Result usage. And debugging is harder because the error’s origin is obscured inside the chain.
  • My recommendation: use the standard (value, error) pattern for all API boundaries and most code. A Result type can be useful internally for complex transformation pipelines where the chaining genuinely improves readability, but do not expose it in public APIs. The Go community strongly favors explicit error handling, and fighting that convention creates more problems than it solves.
Follow-up: What is the zero value of a generic type parameter, and how do you return one when you need a “default” value?You create a zero value with var zero T; return zero. There is no built-in syntax like T{} or default(T) for generic type parameters. This works because Go guarantees every type has a well-defined zero value. For numeric types it is 0, for strings it is "", for pointers/slices/maps it is nil, and for structs it is all fields zero-valued. This pattern appears frequently in generic container types — when a Pop() on an empty stack needs to return something, it returns var zero T along with a false boolean. The idiomatic pattern is func (s *Stack[T]) Pop() (T, bool) where the bool indicates whether a value was available.