Generic Functions — Junior Level¶
Table of Contents¶
- Introduction
- Prerequisites
- Glossary
- Core Concepts
- Real-World Analogies
- Mental Models
- Pros & Cons
- Use Cases
- Code Examples
- Coding Patterns
- Clean Code
- Product Use / Feature
- Error Handling
- Security Considerations
- Performance Tips
- Best Practices
- Edge Cases & Pitfalls
- Common Mistakes
- Common Misconceptions
- Tricky Points
- Test
- Tricky Questions
- Cheat Sheet
- Self-Assessment Checklist
- Summary
- What You Can Build
- Further Reading
- Related Topics
- Diagrams & Visual Aids
Introduction¶
Focus: "What is it?" and "How to use it?"
Before Go 1.18, if you wanted a function that summed a slice of integers, you wrote SumInts. If you also needed to sum floats, you wrote SumFloats. The same logic, copied twice. With Go 1.18 the language gained generic functions: a single function definition that works for many types.
// Pre-1.18 — duplicated
func SumInts(xs []int) int { /* ... */ }
func SumFloats(xs []float64) float64 { /* ... */ }
// Go 1.18+ — one definition
func Sum[T int | float64](xs []T) T {
var s T
for _, x := range xs {
s += x
}
return s
}
The bracketed [T int | float64] part is a type parameter list. T is the type parameter, and the constraint int | float64 says: "T may be either int or float64." When you call Sum([]int{1, 2, 3}) Go infers T = int and the function behaves as if it were written specifically for int.
After reading this file you will: - Understand what a type parameter is and how to declare one - Be able to write your own generic function - Know when to use any vs comparable vs a custom constraint - Recognize when not to reach for generics
Prerequisites¶
- Go 1.18 or newer (
go versionshould print1.18or higher) - Comfort with regular functions (parameters, return values)
- Basic familiarity with slices and maps
- Optional: read 4.1 Why Generics for the motivation
Glossary¶
| Term | Definition |
|---|---|
| Generic function | A function declared with one or more type parameters |
| Type parameter | A placeholder type written inside [...], e.g. T in [T any] |
| Type parameter list | The bracketed list right after the function name: [T any, U comparable] |
| Type argument | The actual concrete type used to instantiate a generic function: Foo[int](42) — here int is the type argument |
| Constraint | An interface-like restriction on what types are allowed for a type parameter |
| Instantiation | The process of substituting type arguments into a generic function to get a "real" function |
| Type inference | The compiler's ability to deduce type arguments from regular argument types so you can write Foo(42) instead of Foo[int](42) |
any | A built-in alias for interface{} — accepts any type |
comparable | A built-in constraint allowing types you can use with == and != |
| Type set | The set of types satisfying a constraint |
| Type union | A constraint of the form int | float64 | string listing allowed types |
Approximation ~T | ~int matches int and any defined type whose underlying type is int |
Core Concepts¶
1. The simplest generic function¶
package main
import "fmt"
func Identity[T any](x T) T {
return x
}
func main() {
fmt.Println(Identity(42)) // 42
fmt.Println(Identity("hi")) // hi
fmt.Println(Identity[bool](true)) // true (explicit type argument)
}
Reading the signature aloud: "Identity is a function that, for any type T, takes a value of type T and returns a value of type T."
2. Anatomy of a generic function¶
func Map[T any, U any](xs []T, f func(T) U) []U
^^^ ^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^ ^^^
name type-param regular params return
list
| Part | Meaning |
|---|---|
Map | Function name |
[T any, U any] | Type parameter list — declares two type parameters with constraint any |
(xs []T, f func(T) U) | Regular parameters, may use the type parameters |
[]U | Return type, may use the type parameters |
3. Calling a generic function¶
// Explicit instantiation — write the type arguments
ys := Map[int, string]([]int{1, 2, 3}, func(x int) string {
return fmt.Sprintf("%d", x)
})
// Type inference — let the compiler figure it out
ys = Map([]int{1, 2, 3}, func(x int) string {
return fmt.Sprintf("%d", x)
})
Both forms produce the same result. Inference is preferred when it's unambiguous and readable.
4. Constraints — telling the compiler what T can do¶
A type parameter on its own is a black box — the compiler does not know what operations are allowed on T. Constraints unlock operations.
// `any` is the loosest constraint — only operations valid for ALL types
func Print[T any](x T) {
fmt.Println(x) // OK — Println accepts any
// x + x // ERROR — `+` is not defined for all T
}
// `comparable` allows == and !=
func Equal[T comparable](a, b T) bool {
return a == b
}
// Custom constraint as an interface
type Number interface {
int | int64 | float64
}
func Add[T Number](a, b T) T {
return a + b
}
5. The any constraint¶
any is just an alias for interface{}. It says: "no constraint." A function with [T any] cannot perform any type-specific operation on T; it can only pass it around, store it, print it via fmt, or compare it via reflection.
6. The comparable constraint¶
comparable is a built-in constraint covering all types where == and != are defined: integers, floats, strings, pointers, channels, interfaces, plus structs/arrays of comparable parts. Slices, maps, and functions are NOT comparable.
func Contains[T comparable](xs []T, target T) bool {
for _, x := range xs {
if x == target {
return true
}
}
return false
}
7. Type union constraints¶
A union lists the allowed concrete types separated by |:
type Numeric interface {
int | int8 | int16 | int32 | int64 |
uint | uint8 | uint16 | uint32 | uint64 |
float32 | float64
}
func Sum[T Numeric](xs []T) T {
var s T
for _, x := range xs { s += x }
return s
}
Inside Sum you may use +, -, *, /, <, >, ==, != — any operator defined on all members of the union.
8. The ~ (approximation) token¶
~int means "any type whose underlying type is int":
type Celsius int
type Fahrenheit int
type IntLike interface {
~int
}
func Double[T IntLike](x T) T { return x * 2 }
var c Celsius = 25
fmt.Println(Double(c)) // 50 (works because Celsius's underlying type is int)
Without ~, Double(c) would fail because Celsius is not literally int.
9. Instantiation — what happens at compile time¶
When you call Sum([]int{1, 2, 3}), the Go compiler instantiates Sum with T = int. Conceptually it produces a specialized version:
// What the compiler effectively builds
func Sum_int(xs []int) int {
var s int
for _, x := range xs { s += x }
return s
}
In practice the Go compiler uses a technique called GC shape stenciling — types with the same memory layout share one compiled body. We dive into this in senior.md.
Real-World Analogies¶
Analogy 1 — A blueprint for a house
A regular function is a finished house. A generic function is a blueprint: the same plan can be used to build a brick house, a wooden house, or a concrete house. The blueprint says: "window goes here, door goes there" without committing to a material. When you actually build (instantiate) you pick the material (the type argument).
Analogy 2 — A waffle iron
A waffle iron makes waffles. The same iron works whether you pour in plain batter, chocolate batter, or buttermilk batter. The iron doesn't care what's inside the batter as long as the batter has the right consistency (the constraint).
Analogy 3 — A mailbox
A mailbox accepts envelopes. It does not care if the envelope contains a letter, a card, or a check — as long as it fits through the slot. The slot dimensions are the constraint.
Analogy 4 — Math notation
In math you write f(x) = x² without committing to whether x is a real, complex, or integer. The formula is generic. When you evaluate f(3) you have instantiated it with x = 3.
Mental Models¶
Model 1: Generic function = template + type arguments¶
Think of a generic function as a template. Each call site is a different filling-in of the template:
Template: func Sum[T Numeric](xs []T) T
│
▼
Call site 1: Sum([]int{...}) → instantiated as Sum_int
Call site 2: Sum([]float64{...}) → instantiated as Sum_float64
Model 2: Type parameter = compile-time variable¶
Regular parameters carry values at runtime. Type parameters carry types at compile time. The compiler "runs" once per instantiation and bakes the type into the resulting code.
Model 3: Constraint = interface for types¶
A constraint is just a special interface. An interface lists method requirements — a constraint may also list allowed concrete types via union.
Model 4: any = no information¶
If you only have [T any] and a value of type T, you can't multiply, add, or compare. You can only pass it through. The fewer constraints you put on T, the fewer operations you can perform.
Pros & Cons¶
Pros¶
| Benefit | Detail |
|---|---|
| Type safety | The compiler enforces types — no interface{} boxing |
| Less duplication | One Map instead of MapInt, MapString, MapFoo |
| Reusable libraries | Authors of slices and maps packages can target many types at once |
Better than interface{} | No type assertions, no boxing/unboxing for common cases |
Cons¶
| Drawback | Detail |
|---|---|
| Cognitive load | Type parameter syntax is new and dense |
| Slightly slower compile | The compiler has to instantiate per shape |
| Possible runtime overhead | GC shape stenciling can require an extra dictionary load |
| Bad fit for I/O-bound code | If your function does network calls, generics buy you nothing |
| Easy to over-abstract | Two specialized functions are often clearer than one over-parameterized one |
Use Cases¶
| Use case | Example |
|---|---|
| Slice utilities | Map, Filter, Reduce, Reverse, Contains |
| Numeric utilities | Sum, Min, Max, Clamp |
| Container helpers | Generic Stack, Queue, Set (the function APIs) |
| Functional helpers | Memoize, Compose, Curry |
| Default-value helpers | Coalesce[T any](xs ...T) T |
| Test helpers | assert.Equal[T comparable](t, expected, actual T) |
When not to use generics: - A single concrete type fits all callers — just write func SumInts([]int) int - The function does I/O — generics don't help here - The logic differs per type — write distinct functions
Code Examples¶
Example 1 — Map¶
package main
import "fmt"
func Map[T any, U any](xs []T, f func(T) U) []U {
out := make([]U, len(xs))
for i, x := range xs {
out[i] = f(x)
}
return out
}
func main() {
nums := []int{1, 2, 3, 4}
squares := Map(nums, func(n int) int { return n * n })
fmt.Println(squares) // [1 4 9 16]
words := []string{"go", "rocks"}
upper := Map(words, func(s string) string { return strings.ToUpper(s) })
fmt.Println(upper) // [GO ROCKS]
}
Example 2 — Filter¶
func Filter[T any](xs []T, pred func(T) bool) []T {
out := make([]T, 0, len(xs))
for _, x := range xs {
if pred(x) {
out = append(out, x)
}
}
return out
}
evens := Filter([]int{1, 2, 3, 4, 5}, func(n int) bool { return n%2 == 0 })
// [2 4]
Example 3 — Reduce¶
func Reduce[T any, U any](xs []T, init U, f func(U, T) U) U {
acc := init
for _, x := range xs {
acc = f(acc, x)
}
return acc
}
sum := Reduce([]int{1, 2, 3, 4}, 0, func(acc, x int) int { return acc + x })
// 10
words := []string{"go", "is", "fun"}
sentence := Reduce(words, "", func(acc, w string) string {
if acc == "" { return w }
return acc + " " + w
})
// "go is fun"
Example 4 — Min / Max¶
type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64 | ~string
}
func Min[T Ordered](a, b T) T {
if a < b { return a }
return b
}
func Max[T Ordered](a, b T) T {
if a > b { return a }
return b
}
fmt.Println(Min(3, 7)) // 3
fmt.Println(Max("apple", "pear")) // pear
(In Go 1.21+ this constraint is provided as cmp.Ordered.)
Example 5 — Contains¶
func Contains[T comparable](xs []T, target T) bool {
for _, x := range xs {
if x == target {
return true
}
}
return false
}
fmt.Println(Contains([]int{1, 2, 3}, 2)) // true
fmt.Println(Contains([]string{"go", "rust"}, "c")) // false
Example 6 — IndexOf¶
func IndexOf[T comparable](xs []T, target T) int {
for i, x := range xs {
if x == target {
return i
}
}
return -1
}
Example 7 — Generic Stack operations¶
We will declare the type itself in 4.3, but its methods can be expressed via generic functions as well:
type Stack[T any] struct {
items []T
}
func Push[T any](s *Stack[T], x T) {
s.items = append(s.items, x)
}
func Pop[T any](s *Stack[T]) (T, bool) {
var zero T
if len(s.items) == 0 {
return zero, false
}
n := len(s.items) - 1
x := s.items[n]
s.items = s.items[:n]
return x, true
}
func main() {
s := &Stack[int]{}
Push(s, 1)
Push(s, 2)
x, _ := Pop(s)
fmt.Println(x) // 2
}
(The more idiomatic form puts these as methods on Stack[T]. We cover that in 4.3.)
Example 8 — Coalesce¶
Returns the first non-zero argument:
func Coalesce[T comparable](vs ...T) T {
var zero T
for _, v := range vs {
if v != zero {
return v
}
}
return zero
}
fmt.Println(Coalesce("", "", "found", "rest")) // "found"
fmt.Println(Coalesce(0, 0, 7, 0)) // 7
Example 9 — Keys and Values of a map¶
func Keys[K comparable, V any](m map[K]V) []K {
out := make([]K, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func Values[K comparable, V any](m map[K]V) []V {
out := make([]V, 0, len(m))
for _, v := range m {
out = append(out, v)
}
return out
}
Example 10 — Reverse¶
func Reverse[T any](xs []T) {
for i, j := 0, len(xs)-1; i < j; i, j = i+1, j-1 {
xs[i], xs[j] = xs[j], xs[i]
}
}
Coding Patterns¶
Pattern 1: Identity helper¶
func Identity[T any](x T) T { return x } — useful as a default in pipelines.
Pattern 2: Zero-value helper¶
Pattern 3: Pointer helper¶
func Ptr[T any](x T) *T {
return &x
}
p := Ptr(42) // *int pointing at 42 — handy when you need a pointer to a literal
Pattern 4: Map keys to slice¶
type Pair[K comparable, V any] struct{ K K; V V }
func Pairs[K comparable, V any](m map[K]V) []Pair[K, V] {
out := make([]Pair[K, V], 0, len(m))
for k, v := range m {
out = append(out, Pair[K, V]{k, v})
}
return out
}
Pattern 5: Apply transformation chain¶
Clean Code¶
- Use single-letter type names (
T,U,K,V) when meaning is obvious from context — that is the Go convention. - For more domain-specific generics, use longer descriptive names (
Element,Key,Value). - Keep type parameter lists short — three is already a lot.
- Prefer
anyoverinterface{}in type-parameter contexts. - Keep generic helpers in a dedicated file (e.g.,
slicesx.go). - Don't make a function generic just because you can. If only one concrete type uses it, leave it concrete.
Product Use / Feature¶
You are building a SaaS app. A few real generic-function use cases:
- Pagination helper.
func Page[T any](items []T, offset, limit int) []T— works for any kind of result. - Webhook batch.
func Batch[T any](xs []T, size int) [][]T— chunk events into HTTP-sized batches. - Cache wrapper.
func Cached[K comparable, V any](key K, fetch func(K) V) V— memoization helper. - Bulk DB upsert.
func Upsert[T Identifiable](db *DB, items []T) error— works for any row type that exposes anID()method.
Error Handling¶
Generic functions handle errors like any other function — there is no special syntax.
func MapErr[T any, U any](xs []T, f func(T) (U, error)) ([]U, error) {
out := make([]U, len(xs))
for i, x := range xs {
y, err := f(x)
if err != nil {
return nil, fmt.Errorf("MapErr at index %d: %w", i, err)
}
out[i] = y
}
return out, nil
}
Tip: when wrapping errors, include the index or key so the caller can localize the failure.
Security Considerations¶
- Generics are a compile-time feature; they introduce no new runtime attack surface vs ordinary code.
- However, generic helpers are tempting to use in places where you should validate input. A
Mapfunction will dutifully apply your closure to every element — including elements that came from untrusted sources. Validate before mapping, not inside the mapper. - Beware of accidentally widening exposure:
func Public[T any](x T) Tis more permissive than a typed function. If the function operates on credentials, prefer a specific type.
Performance Tips¶
- Generics are usually the same speed as hand-written code, but a dispatching layer ("dictionary") may add a small overhead — typically 1-5%. We cover this in
optimize.md. - Avoid
[T any]if you only need numeric or string types — pick a tighter constraint so the compiler can specialize harder. - For hot paths over slices of
intorfloat64, a hand-written loop can still beat the generic version by a few percent. Measure before reaching for generics in tight inner loops. - Reuse the output slice (
make([]U, len(xs))) — you already know the final length forMap.
Best Practices¶
| Best practice | Reason |
|---|---|
| Start non-generic, generalize later | Premature generalization is a real cost |
| Pick the tightest possible constraint | Helps the reader and the compiler |
Use any for true container helpers | Filter, Reverse, First, ... |
Use comparable for equality-based helpers | Contains, IndexOf, Distinct |
Use cmp.Ordered (Go 1.21+) for ordering | Min, Max, sorting |
| Provide examples in doc comments | Generics' signatures are dense; examples help |
| Avoid adding type parameters that aren't used | Each unused type parameter is dead weight |
Edge Cases & Pitfalls¶
Cannot infer T when it appears only in the return type¶
func Make[T any]() T {
var z T
return z
}
x := Make() // ERROR — compiler cannot infer T
y := Make[int]() // OK
Methods cannot have their own type parameters¶
type Box[T any] struct{ V T }
// LEGAL — uses Box's type parameter
func (b Box[T]) Get() T { return b.V }
// ILLEGAL — methods may not declare new type parameters
// func (b Box[T]) Map[U any](f func(T) U) Box[U] { ... } // compile error
You must drop the parameter to a free function:
Empty slice with [T any]¶
Closures capture the type parameter¶
That works fine, but every call to MakeAdder[int] produces a separate closure with its own captured base — no surprises here, but worth noting for memory.
Common Mistakes¶
| Mistake | Fix |
|---|---|
[T any] then trying t1 + t2 | Add a numeric constraint |
Forgetting ~ on union constraints | Use ~int if you want defined types like Celsius to fit |
Using interface{} instead of any | Use any — it's the modern alias |
| Putting type parameters on methods | Move them to the receiver type or to a free function |
| Over-parameterizing | If only one type ever uses it, drop generics |
Writing Foo[int, string](...) when inference works | Trust inference for readability |
Common Misconceptions¶
"Generics are like Java/C# generics."
Surface syntax is similar but Go uses type parameter constraints rather than wildcards (<? extends T>). There is no covariance/contravariance and no method-level type parameters.
"Generics are slower than interface{}."
Usually faster, because there is no boxing for primitive types. We benchmark this in optimize.md.
"any and interface{} differ at runtime."
They do not — any is a literal alias.
"Generics replace interfaces."
They don't. Generics are best for operating on types, interfaces are best for describing behavior.
Tricky Points¶
-
Type inference with literals.
Min(1, 2.0)is ambiguous — the compiler can't decide betweenintandfloat64. Convert one operand or specifyMin[float64](1, 2.0). -
[T comparable]vs interfaces. A type is comparable if==works on it. A struct of all-comparable fields is comparable; a struct with a slice field is not. -
Empty type set is illegal.
[T int & string]is empty (no type is bothintandstring). The compiler rejects it. -
Type parameters can constrain each other.
We touch on this in senior.md.
- Reflect on a type parameter.
reflect.TypeOf(x)works on aTvalue at runtime — the type is concrete by then.
Test¶
Quick check (answers below).
- What does
[T any]mean? - True or false:
func (b Box[T]) Map[U any]() Box[U]is legal Go. - Which constraint allows
==?anyorcomparable? - Does
~intacceptint? Does it accept atype Age int? - Why can't
Min(1, 2.0)infer a singleT? - What is instantiation?
Answers
1. T is a type parameter unconstrained — any type is allowed. 2. False — methods cannot have their own type parameters. 3. `comparable`. 4. Yes to both. 5. Because `1` is `int` and `2.0` is `float64`; there is no single `T` matching both. 6. The compiler substituting type arguments to produce a concrete function.Tricky Questions¶
Q1. Why can't Sum[T any](xs []T) T compile? A. Because + is not defined for all types. The constraint is too weak; use a numeric constraint.
Q2. Why is any preferred over interface{} in modern Go? A. Readability. They are identical otherwise.
Q3. When should you pick [T comparable] over [T any]? A. Whenever the function uses == or != (lookup, deduplication, equality).
Q4. Why are some types not comparable? A. Slices, maps, and functions have no defined == (they would compare references but the language disallows it for slices/maps to avoid surprising semantics).
Q5. Can the same type parameter name appear twice in [T any, T any]? A. No — that's a duplicate name and is rejected.
Cheat Sheet¶
// Declaration
func Foo[T any](x T) T { return x }
func Pair[K comparable, V any](k K, v V) struct{K K; V V} { ... }
// Constraints
[T any] // any type
[T comparable] // == and != allowed
[T int | float64] // type union
[T ~int] // Approximation: int and types with underlying int
[T cmp.Ordered] // Go 1.21+ ordered types
[T fmt.Stringer] // Method-set constraint
// Calling
Foo(42) // inference — common case
Foo[int](42) // explicit type argument
// Multiple type params
func Map[T, U any](xs []T, f func(T) U) []U
Map([]int{1,2,3}, strconv.Itoa)
Self-Assessment Checklist¶
- I can declare a generic function with one type parameter
- I can declare a generic function with multiple type parameters
- I can pick
anyvscomparablecorrectly - I can use
~Tand explain when it matters - I can write
Map,Filter,Reducefrom memory - I know when type inference will fail
- I understand why methods can't add their own type parameters
- I can call a generic function with explicit type arguments
- I avoid using
interface{}whenanywill do - I can explain instantiation in two sentences
Summary¶
Generic functions in Go let you write one function that works on many types without sacrificing type safety or performance. The key parts are the type parameter list ([T any]), constraints (any, comparable, custom unions), and instantiation (the compiler producing the specialized version). Use generics when the logic is identical across types, and avoid them when the logic differs or only one type ever uses the function.
What You Can Build¶
After mastering generic functions you can build: - A slicesx utility package with Map, Filter, Reduce, Uniq, GroupBy - A typed memoization helper for any function K -> V - A simple type-safe set: Set[T]{ Add, Remove, Contains } - A pipeline framework for stream-processing - A test-assertion library with assert.Equal[T comparable](t, expected, got T)
Further Reading¶
- Go blog: An Introduction to Generics
- Go spec: Function declarations
- Go spec: Type parameter declarations
- Tutorial: Getting started with generics
- Proposal: type parameters
Related Topics¶
- 4.1 Why Generics — motivation and history
- 4.3 Generic Types & Interfaces — generic struct and interface declarations
- 4.4 Type Constraints — designing your own constraints
- 4.5 Type Inference — deeper dive on inference rules
- 3.1 Methods vs Functions — why methods cannot have their own type parameters
Diagrams & Visual Aids¶
Anatomy¶
┌─ name
│
│ ┌─ type parameter list
│ │
│ │ ┌─ constraint
│ │ │
▼ ▼ ▼
func Map[T any, U any](xs []T, f func(T) U) []U
└────────┬─────────┘ └┬┘
regular params return
Instantiation¶
┌────────────────┐
Sum[int] ─────────► │ body with │
│ T → int │
└────────────────┘
┌────────────────┐
Sum[float64] ─────► │ body with │
│ T → float64 │
└────────────────┘