Table of contents
- A Long-Awaited Feature
- Type Parameters — Basic Syntax
- Constraints
- Generic Types
- Table-Driven Tests
- Build Tags
- go generate
- Wrapping Up the Series
A Long-Awaited Feature
Go was a language without generics for a long time. “When will you add generics?” was the most frequently asked question in the Go community, and the Go team maintained for nearly 10 years that they hadn’t found the right design yet. Then in 2022, Go 1.18 finally introduced generics. It wasn’t a rush job — it was the result of thorough deliberation, preserving Go’s characteristic simplicity while adding the necessary expressiveness.
Type Parameters — Basic Syntax
Before generics, you had to either write the same logic repeatedly for each type or accept any (then interface{}) and use type assertions. Both approaches had clear drawbacks.
// Without generics: had to write separate versions for int, float64, string
func ContainsInt(slice []int, target int) bool { /* ... */ }
func ContainsFloat(slice []float64, target float64) bool { /* ... */ }
func ContainsString(slice []string, target string) bool { /* ... */ }
With generics, a single function handles all cases.
package main
import "fmt"
func Contains[T comparable](slice []T, target T) bool {
for _, v := range slice {
if v == target {
return true
}
}
return false
}
func main() {
fmt.Println(Contains([]int{1, 2, 3}, 2)) // true
fmt.Println(Contains([]string{"a", "b"}, "c")) // false
fmt.Println(Contains([]float64{1.1, 2.2}, 2.2)) // true
}
In [T comparable], T is the type parameter and comparable is the constraint. It means “any type that supports comparison can be used as T.” You don’t even need to specify the type when calling — it’s inferred from the arguments.
Visualizing the relationship between type parameters and constraints makes it clear that the constraint decides which types are allowed.
flowchart LR
Decl["Contains[T comparable]"] --> T[T]
T --> C{Constraint: comparable}
C -->|allowed| Int[int]
C -->|allowed| Str[string]
C -->|allowed| Float[float64]
C -->|rejected| Map["map[K]V<br/>(not comparable)"]
C -->|rejected| Slice["[]T<br/>(not comparable)"]
Constraints
Constraints define the conditions a type parameter must meet. In Go, constraints are expressed as interfaces.
package main
import (
"fmt"
"golang.org/x/exp/constraints"
)
// Define a custom constraint
type Number interface {
int | int8 | int16 | int32 | int64 |
float32 | float64
}
func Sum[T Number](numbers []T) T {
var total T
for _, n := range numbers {
total += n
}
return total
}
func main() {
ints := []int{1, 2, 3, 4, 5}
fmt.Println(Sum(ints)) // 15
floats := []float64{1.1, 2.2, 3.3}
fmt.Println(Sum(floats)) // 6.6
}
The | operator lists allowed types. Adding ~ like ~int includes all user-defined types based on int.
type Celsius float64
type Fahrenheit float64
type Temperature interface {
~float64 // Allows both Celsius and Fahrenheit
}
func Average[T Temperature](temps []T) T {
var sum T
for _, t := range temps {
sum += t
}
return sum / T(len(temps))
}
There are also built-in constraints frequently used in the standard library.
| Constraint | Meaning |
|---|---|
any | Any type |
comparable | Types that support ==, != comparison |
cmp.Ordered | Types that support <, > ordering comparisons (Go 1.21+) |
Generic Types
Generics can be applied not just to functions but also to types. This is especially useful for building data structures.
package main
import "fmt"
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
}
last := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return last, 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]) Size() int {
return len(s.items)
}
func main() {
// int stack
intStack := &Stack[int]{}
intStack.Push(1)
intStack.Push(2)
intStack.Push(3)
val, _ := intStack.Pop()
fmt.Println(val) // 3
// string stack
strStack := &Stack[string]{}
strStack.Push("hello")
strStack.Push("world")
top, _ := strStack.Peek()
fmt.Println(top) // world
}
Stack[int] and Stack[string] are completely type-safe. Trying to push a string into an int stack causes a compile error, and values popped out are already the correct type — no type assertions needed. Before generics, stacks built with interface{} required assertions every time you extracted a value, so the code is much cleaner now.
Visualizing how generic types get “stamped” into concrete types looks like this.
classDiagram
class Stack~T~ {
-items []T
+Push(item T)
+Pop() (T, bool)
+Peek() (T, bool)
+Size() int
}
class Stack_int {
-items []int
+Push(int)
+Pop() (int, bool)
}
class Stack_string {
-items []string
+Push(string)
+Pop() (string, bool)
}
Stack <|.. Stack_int : T = int
Stack <|.. Stack_string : T = string
Table-Driven Tests
The most widely used Go testing idiom is the table-driven test. Test cases are defined as a slice, and a loop executes them one by one.
package calc
import "testing"
func Add(a, b int) int {
return a + b
}
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive numbers", 2, 3, 5},
{"negative numbers", -1, -2, -3},
{"zero and positive", 0, 5, 5},
{"large numbers", 1000000, 2000000, 3000000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.expected {
t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.expected)
}
})
}
}
go test -v ./calc/
# === RUN TestAdd
# === RUN TestAdd/positive_numbers
# === RUN TestAdd/negative_numbers
# === RUN TestAdd/zero_and_positive
# === RUN TestAdd/large_numbers
# --- PASS: TestAdd (0.00s)
The advantage of this pattern is how easy it is to add cases. When a new edge case is discovered, just add one line to the slice. With t.Run creating subtests, you can immediately see which case failed, and the -run flag lets you run specific cases.
The Go standard library’s own test code uses this pattern extensively. If you’re writing Go code, this pattern is an essential skill to master.
Build Tags
Build tags are a mechanism for including or excluding specific files at compile time. You can build different code per OS, architecture, or custom conditions.
//go:build linux
package platform
func GetOS() string {
return "Linux"
}
//go:build darwin
package platform
func GetOS() string {
return "macOS"
}
//go:build windows
package platform
func GetOS() string {
return "Windows"
}
Three files with the same package and function signature, but build tags ensure only one gets compiled. On Linux the first file is included, on macOS the second, and so on.
Custom tags are also possible.
//go:build integration
package myapp
import "testing"
func TestDatabaseConnection(t *testing.T) {
// Integration test — requires actual DB
// go test -tags integration ./...
}
The -tags flag activates custom tags. This is useful for separating slow integration tests from default tests. In CI you run go test -tags integration for the full suite, while locally you run only quick unit tests.
go generate
go generate is a command that runs code generation tools. Special comments in source files trigger commands when you run go generate ./....
package main
//go:generate stringer -type=Color
type Color int
const (
Red Color = iota
Green
Blue
)
go install golang.org/x/tools/cmd/stringer@latest
go generate ./...
Running this command makes the stringer tool auto-generate a String() method for each Color constant. A file like color_string.go is created, so fmt.Println(Red) outputs “Red” instead of “0.”
The key point about go generate is that it’s not part of the build process. It must be explicitly run by the developer, and generated files are included in version control. It’s a development-time tool, not a build-time one.
Here are a few commonly used code generation tools:
- stringer: Generates String() methods for enum constants
- mockgen: Generates mock implementations of interfaces (for testing)
- sqlc: Generates type-safe Go code from SQL queries
- protoc-gen-go: Generates Go code from Protocol Buffer definitions
Wrapping Up the Series
From Part 7 through Part 12, we’ve covered Go’s core topics from intermediate to practical levels. We’ve seen how interfaces’ implicit implementation reduces coupling, how pointers overcome the limitations of pass-by-value, and how goroutines and channels make concurrency feel natural. We explored how the package system’s simple rules support large projects, and how generics eliminate repetition.
Go’s appeal doesn’t end at being easy to learn. Its real strength is that you can build complex systems from combinations of simple tools. With just what this series has covered, you have enough foundation to build web servers, CLI tools, and concurrent programs. The rest can only be filled in by writing code yourself. I recommend experimenting on the Go Playground and starting a small project.



Loading comments...