Generics: Late to the Party

Go shipped generics in version 1.18 (March 2022). C# has had them since 2.0 (November 2005). That’s a seventeen-year head start, and it shows.

If you’re coming from C# expecting the same power and flexibility, you’ll be disappointed. Go’s generics are deliberately constrained—they solve the common cases but won’t let you build the same abstractions you’re used to.

Let’s look at what we’ve got, what’s missing, and whether that matters.

The Basic Syntax

A generic function in Go:

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

// Usage
numbers := []int{1, 2, 3, 4}
squared := Map(numbers, func(n int) int { return n * n })

Compare to C#:

public static IEnumerable<U> Map<T, U>(IEnumerable<T> items, Func<T, U> f)
{
    foreach (var item in items)
        yield return f(item);
}

// Usage  
var numbers = new[] { 1, 2, 3, 4 };
var squared = Map(numbers, n => n * n);

The syntax is different but the concept is familiar. Type parameters go in square brackets in Go ([T, U any]) rather than angle brackets (<T, U>). The any is a constraint—more on that shortly.

Constraints

Here’s where Go’s approach diverges. In C#, you constrain type parameters with where:

public T Max<T>(T a, T b) where T : IComparable<T>
{
    return a.CompareTo(b) > 0 ? a : b;
}

In Go, constraints are interfaces:

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

The cmp.Ordered constraint (from the standard library) allows the > operator. Without it, Go wouldn’t know that T supports comparison.

Built-in Constraints

Go provides some standard constraints in the constraints and cmp packages:

ConstraintWhat it allows
anyAny type (alias for interface{})
comparableTypes that support == and !=
cmp.OrderedTypes that support <, >, <=, >=
constraints.IntegerAll integer types
constraints.FloatAll float types
constraints.SignedSigned integers
constraints.UnsignedUnsigned integers

Custom Constraints

You can define your own constraints as interfaces:

type Number interface {
    ~int | ~int64 | ~float64
}

func Sum[T Number](items []T) T {
    var total T
    for _, item := range items {
        total += item
    }
    return total
}

That ~int syntax means “any type whose underlying type is int”. This lets you include type aliases and defined types, not just the primitive itself.

Generic Types

Generic structs work as you’d expect:

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
}

// Usage
stack := &Stack[string]{}
stack.Push("hello")
value, ok := stack.Pop()

Where the Gaps Hurt

Right, here’s the honest bit. Go’s generics are limited in ways that’ll frustrate you coming from C#.

No Generic Methods on Non-Generic Types

In C#, you can do this:

public class Converter
{
    public T Convert<T>(string value) { ... }
}

In Go, you can’t add a generic method to a non-generic type. This doesn’t compile:

type Converter struct{}

// ERROR: method must have no type parameters
func (c Converter) Convert[T any](value string) T { ... }

You have to use a generic function instead:

func Convert[T any](c Converter, value string) T { ... }

It works, but it’s clunkier and doesn’t chain as nicely.

No Specialisation

C# lets you specialise behaviour based on type arguments (with runtime checks or partial specialisation). Go doesn’t. You can’t do:

func Process[T any](item T) {
    // Can't check "if T is string, do this"
    // Can't have different implementations for different T
}

Everything must work uniformly for all types that satisfy the constraint.

No Variance

C# has covariance (out T) and contravariance (in T) for generic interfaces. Go has neither. A Stack[Dog] is not a Stack[Animal], even if Dog implements Animal. Ever.

No Arithmetic Constraints (Sort Of)

Want to write a generic sum function? You need to constrain to types that support +:

func Sum[T constraints.Integer | constraints.Float](items []T) T {
    var total T
    for _, item := range items {
        total += item
    }
    return total
}

This works, but you can’t easily extend it to custom numeric types. The constraint system is based on underlying types, not operators.

No Default Type Arguments

C# has:

public class Cache<TKey, TValue, TSerializer = JsonSerializer> { }

Go doesn’t. Every type parameter must be specified at use sites (though type inference helps for functions).

The Standard Library’s Generics

Go 1.21+ added generic functions to the standard library. These are genuinely useful:

import (
    "maps"
    "slices"
)

// Slices
numbers := []int{3, 1, 4, 1, 5, 9}
slices.Sort(numbers)
found := slices.Contains(numbers, 4)
maxVal := slices.Max(numbers)

// Maps  
m := map[string]int{"a": 1, "b": 2}
keys := maps.Keys(m)    // iterator over keys
values := maps.Values(m) // iterator over values
maps.Clone(m)           // shallow copy

Before generics, you’d write these loops by hand every time. Now they’re type-safe and reusable.

When You’ll Feel the Gaps

Building Fluent APIs

In C#, you might build a fluent configuration API with generic methods:

builder
    .WithOption<ConnectionOptions>(opts => opts.Timeout = 30)
    .WithOption<RetryOptions>(opts => opts.MaxRetries = 3)
    .Build();

This pattern is harder in Go because you can’t have generic methods. You’d need a different approach—probably top-level functions or a less fluent API.

Repository Patterns

The classic IRepository<T> with Find<TKey> is awkward:

// This doesn't work - can't have generic method
type Repository[T any] interface {
    Find[K comparable](id K) (T, error)  // ERROR
}

// You'd need this instead
type Repository[T any, K comparable] interface {
    Find(id K) (T, error)
}

Two type parameters instead of one, threaded through everything.

LINQ-style Operations

C# LINQ chains beautifully with generic extension methods:

items.Where(x => x.Active).Select(x => x.Name).OrderBy(x => x).ToList();

Go has no extension methods. You’d write:

result := slices.Sorted(
    slices.Collect(
        Map(
            Filter(items, func(x Item) bool { return x.Active }),
            func(x Item) string { return x.Name },
        ),
    ),
)

Functional, but not as elegant.

The Practical Advice

After working with Go’s generics, here’s how I use them:

Do use generics for:

  • Data structures (stacks, queues, trees, caches)
  • Collection utilities (map, filter, reduce)
  • Type-safe wrappers around interface{}
  • Eliminating repetitive code across similar types

Don’t use generics for:

  • Everything. Go’s non-generic code is often clearer.
  • Patterns that require generic methods (rethink the design)
  • Complex type relationships (the constraint syntax gets unwieldy)

Accept these limitations:

  • Write more concrete types, fewer abstractions
  • Use interfaces for polymorphism, generics for type safety
  • Sometimes duplicate code is clearer than a generic contortion

The Honest Assessment

AspectC# GenericsGo Generics
Maturity20 years3 years
Generic methodsYesOnly on generic types
VarianceFull (in/out)None
ConstraintsRich (interfaces, new(), class, struct)Interfaces only
SpecialisationPartial (with runtime checks)None
Standard libraryExtensiveGrowing

Go’s generics are good enough for most real-world needs. They eliminate the pre-1.18 pain of interface{} everywhere or code generation. But they’re not trying to match C#’s power.

The Go team explicitly chose to ship a simpler system first, with room to expand. Whether the gaps will be filled or the community will adapt around them remains to be seen.

For now? Lower your expectations, use generics where they help, and don’t twist yourself into knots trying to replicate C# patterns that don’t translate well.


Next up: nil in Go versus nullable reference types in C#—two different approaches to the billion dollar mistake, and why both languages still get caught out.