Skip to main content

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

Type Parameters

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

Basic Syntax

Simple Generic Function

Multiple Type Parameters


Type Constraints

Constraints define what operations can be performed on type parameters.

The any Constraint

any is an alias for interface{} - allows any type.

The comparable Constraint

comparable allows types that support == and != operators.

The constraints Package

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

Custom Constraints

Define your own constraints using interface syntax:

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.
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 Linked List

Generic Map/Dictionary


Common Generic Patterns

Filter, Map, Reduce

Result Type (Error Handling)

Optional Type


Type Inference

Go can often infer type parameters from function arguments:

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

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:
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:

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


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.