Type Parameters And Generics
Overview
Generics in Go provide a powerful way to write reusable and type-safe code. They allow developers to write functions and data structures that can work with different types, without sacrificing type safety. With generics, we can create flexible and efficient algorithms that can be used with various data types, reducing code duplication and improving code maintainability. Generics enable us to write more generic and abstract code, making our programs more flexible and adaptable to different scenarios.
Generics
Generics allow us to write functions and types that work with multiple types while maintaining compile-time type safety.
Generics were introduced in Go 1.18 to solve problems that interfaces alone couldn’t address elegantly. They provide a way to write reusable code without sacrificing performance or type safety.
Basic Generic Syntax
Before we dive into why generics are useful, let’s understand the basic syntax. Generic functions use square brackets [] to declare type parameters:
func Name[T any](parameters) (returns) {
// ...
}
Let’s break down this syntax:
- Function Name:
Nameis the regular function name - Type Parameters:
[T any]declares a type parameter calledTTis a placeholder for the actual type that will be usedanyis a constraint meaningTcan be any type
- Parameters: Use
Tin the parameter list to refer to the generic type - Returns: Can also use
Tin the return type
Echo Example
Here T is a type parameter that can be any type. Let’s see this in action:
package main
import "fmt"
// section: function
func Echo[T any](input T) T {
return input
}
// section: function
func main() {
// Works with any type
fmt.Println(Echo("Hello, World!"))
fmt.Println(Echo(42))
fmt.Println(Echo(3.14))
fmt.Println(Echo(true))
}Let’s understand what’s happening here:
- Generic Function:
Echo[T any](input T) Taccepts any typeT - Type Parameter Usage: The parameter
inputis of typeT - Type Inference: When we call
Echo("Hello, World!"), Go infers thatTisstring - Multiple Types: The same function works with strings, integers, and booleans
This function can work with any type - strings, integers, booleans, or custom types.
But you might be wondering: “Why is this better than just using interfaces?”
Why Do We Need Generics?
Great question! While interfaces are powerful for many use cases, they have limitations when we need to maintain type safety across operations. The simple example we just saw doesn’t really show the power of generics.
Let’s explore a more realistic scenario that demonstrates why generics are essential.
The Problem With Interfaces
Consider a function that returns the keys of a map. Using interfaces, we might write:
func Keys(m map[any]any) []any {
keys := make([]any, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
package main
import "fmt"
// section: function
func Keys(m map[any]any) []any {
keys := make([]any, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
// section: function
func main() {
// section: usage
// This is cumbersome - we lose type safety
stringIntMap := map[string]int{"a": 1, "b": 2}
// We need to convert to map[any]any
anyMap := make(map[any]any)
for k, v := range stringIntMap {
anyMap[k] = v
}
keys := Keys(anyMap)
fmt.Println("Keys:", keys)
// We need to assert types when using the results
for _, key := range keys {
if strKey, ok := key.(string); ok {
fmt.Println("String key:", strKey)
}
}
// section: usage
}Let’s break down what’s happening here:
- Function Signature: We accept a map with
anykeys andanyvalues - Key Extraction: We iterate through the map and collect all keys into a slice
- Return Type: We return
[]anybecause we don’t know the specific type of keys
While this works, it’s difficult to use because we lose type safety:
// This is cumbersome - we lose type safety
stringIntMap := map[string]int{"a": 1, "b": 2}
// We need to convert to map[any]any
anyMap := make(map[any]any)
for k, v := range stringIntMap {
anyMap[k] = v
}
keys := Keys(anyMap)
fmt.Println("Keys:", keys)
// We need to assert types when using the results
for _, key := range keys {
if strKey, ok := key.(string); ok {
fmt.Println("String key:", strKey)
}
}package main
import "fmt"
// section: function
func Keys(m map[any]any) []any {
keys := make([]any, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
// section: function
func main() {
// section: usage
// This is cumbersome - we lose type safety
stringIntMap := map[string]int{"a": 1, "b": 2}
// We need to convert to map[any]any
anyMap := make(map[any]any)
for k, v := range stringIntMap {
anyMap[k] = v
}
keys := Keys(anyMap)
fmt.Println("Keys:", keys)
// We need to assert types when using the results
for _, key := range keys {
if strKey, ok := key.(string); ok {
fmt.Println("String key:", strKey)
}
}
// section: usage
}Notice the problems with this approach:
- Type Conversion: We must convert our typed map to
map[any]anybefore calling the function - Loss of Type Information: The returned slice is
[]any, not[]string - Type Assertions: We need to assert types when using the results (
key.(string)) - Runtime Errors: Type assertions can panic if we get the types wrong
- No Compile-Time Safety: The compiler can’t help us catch type-related errors
This is exactly the kind of problem generics were designed to solve - maintaining type safety while providing flexibility.
Solving With Generics
Now let’s see how generics elegantly solve this problem while maintaining type safety:
func Keys[K comparable, V any](m map[K]V) []K {
keys := make([]K, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
package main
import "fmt"
// section: function
func Keys[K comparable, V any](m map[K]V) []K {
keys := make([]K, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
// section: function
func main() {
// section: usage
// Much cleaner - type safety is maintained
stringIntMap := map[string]int{"a": 1, "b": 2, "c": 3}
intStringMap := map[int]string{1: "one", 2: "two", 3: "three"}
stringKeys := Keys(stringIntMap)
intKeys := Keys(intStringMap)
fmt.Printf("String Keys\t Type: %[1]T\tKeys: %[1]v\n", stringKeys)
fmt.Printf("Int Keys\t Type: %[1]T\tKeys: %[1]v\n", intKeys)
// No type assertions needed!
for _, key := range stringKeys {
fmt.Printf("Key: %s, Value: %d\n", key, stringIntMap[key])
}
// section: usage
}Let’s examine the generic function signature:
- Type Parameters:
[K comparable, V any]declares two type parametersKis the key type, constrained tocomparable(can be used with==and!=)Vis the value type, with no constraints (any)
- Function Parameter:
m map[K]Vaccepts a map with keys of typeKand values of typeV - Return Type:
[]Kreturns a slice of keys with the same type as the map’s keys - Type Safety: The compiler ensures all types match throughout the function
Now we can use this function with any map type:
// Much cleaner - type safety is maintained
stringIntMap := map[string]int{"a": 1, "b": 2, "c": 3}
intStringMap := map[int]string{1: "one", 2: "two", 3: "three"}
stringKeys := Keys(stringIntMap)
intKeys := Keys(intStringMap)
fmt.Printf("String Keys\t Type: %[1]T\tKeys: %[1]v\n", stringKeys)
fmt.Printf("Int Keys\t Type: %[1]T\tKeys: %[1]v\n", intKeys)
// No type assertions needed!
for _, key := range stringKeys {
fmt.Printf("Key: %s, Value: %d\n", key, stringIntMap[key])
}package main
import "fmt"
// section: function
func Keys[K comparable, V any](m map[K]V) []K {
keys := make([]K, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
// section: function
func main() {
// section: usage
// Much cleaner - type safety is maintained
stringIntMap := map[string]int{"a": 1, "b": 2, "c": 3}
intStringMap := map[int]string{1: "one", 2: "two", 3: "three"}
stringKeys := Keys(stringIntMap)
intKeys := Keys(intStringMap)
fmt.Printf("String Keys\t Type: %[1]T\tKeys: %[1]v\n", stringKeys)
fmt.Printf("Int Keys\t Type: %[1]T\tKeys: %[1]v\n", intKeys)
// No type assertions needed!
for _, key := range stringKeys {
fmt.Printf("Key: %s, Value: %d\n", key, stringIntMap[key])
}
// section: usage
}Notice the improvements:
- No Type Conversion: We can pass our maps directly without converting to
map[any]any - Type Safety: The returned slices have the correct types (
[]stringand[]int) - No Type Assertions: We can use the keys directly without type assertions
- Compile-Time Checking: The compiler catches type errors at compile time
- Clean Code: The usage is much cleaner and more intuitive
The beauty of this approach is that we maintain complete type safety while gaining flexibility. But generics can do even more than this simple example.
Understanding The Comparable Constraint
You might have noticed we used comparable as a constraint for the map key type K. This is a special built-in constraint in Go that’s essential for working with maps and certain operations. Let’s understand what it means and why it’s important.
What is Comparable?
The comparable constraint is a predeclared identifier in Go (like int, string, any) that represents all types that can be compared using the == and != operators. This is crucial for map keys because Go needs to compare keys to determine if they already exist in the map.
Types That Satisfy Comparable:
✅ Always Comparable:
- All boolean types (
bool) - All numeric types (
int,int8,int16,int32,int64,uint,uint8,uint16,uint32,uint64,float32,float64,complex64,complex128) - All string types (
string) - Pointer types (
*T) - Channel types (
chan T) - Interface types (can be compared to
nilor other interface values) - Arrays of comparable types (
[N]TwhereTis comparable) - Structs where all fields are comparable
❌ Never Comparable:
- Slices (
[]T) - Maps (
map[K]V) - Functions (
func())
Why Map Keys Must Be Comparable:
Maps in Go are implemented as hash tables. When you insert or look up a value, Go needs to:
- Hash the key to find the bucket
- Compare the key with existing keys in that bucket using
==
Without the ability to compare keys, Go couldn’t determine if a key already exists in the map.
Example with Different Comparable Types:
// All these work because the key types are comparable
var intMap map[int]string // int is comparable
var stringMap map[string]int // string is comparable
var ptrMap map[*User]string // pointers are comparable
var structMap map[Point]string // structs with comparable fields are comparable
// These would cause compile errors
// var sliceMap map[[]int]string // ❌ slices are not comparable
// var mapMap map[map[string]int]bool // ❌ maps are not comparable
When to Use Comparable vs Ordered:
- Use
comparable: When you need to check equality (==,!=) - perfect for map keys, checking if values are equal - Use
constraints.Ordered: When you need ordering comparisons (<,<=,>=,>) - for sorting, finding min/max values
constraints.Ordered is more restrictive than comparable - all ordered types are comparable, but not all comparable types are ordered (e.g., complex numbers are comparable but not ordered).
How Go Generics Work Behind The Scenes
Important: Go generics are NOT implemented using traditional monomorphization (code generation) like C++ templates or Rust. This is a common misconception.
Instead, Go uses a hybrid approach called “GCShape stenciling with dictionaries”:
- GCShape Grouping: Types with the same underlying type or all pointer types share the same compiled function
- Dictionary Passing: Every generic function call receives a hidden dictionary parameter (passed in register AX on x86_64)
- Runtime Resolution: Method calls and type operations use the dictionary to resolve types at runtime
Real Example of GCShape Grouping
What you write:
func process[T int | string | *User](data T) {
// ... some processing
}
func main() {
process(42) // int
process("hello") // string
process(&User{}) // *User
process(&Product{}) // *Product
}
What the compiler actually generates:
// Shape for int (distinct from string)
func process[go.shape.int_0](dict *Dictionary, data go.shape.int_0)
// Shape for string (distinct from int)
func process[go.shape.string_0](dict *Dictionary, data go.shape.string_0)
// Shape for ALL pointer types (*User, *Product, *int, *string, etc.)
func process[go.shape.*uint8_0](dict *Dictionary, data go.shape.*uint8_0)
The Dictionary Contains:
- Runtime type information for each type parameter
- Method tables (itabs) for calling methods on generic types
- Type conversion information for interface conversions
Performance Implications:
- Compile-time: Faster than full monomorphization
- Runtime: Can be slower than interfaces due to dictionary lookups
- Method calls: Require double indirection (dictionary → itab → method)
Multiple Type Parameters
For more complex scenarios, we can specify multiple type parameters in a single function:
package main
import "fmt"
// section: function
func Transform[T any, U any](input T, transformer func(T) U) U {
return transformer(input)
}
// section: function
func main() {
// section: usage
// Transform string to int
result1 := Transform("hello", func(s string) int {
return len(s)
})
fmt.Println("String length:", result1)
// Transform int to string
result2 := Transform(42, func(i int) string {
return fmt.Sprintf("Number: %d", i)
})
fmt.Println("Formatted number:", result2)
// Transform int to bool
result3 := Transform(10, func(i int) bool {
return i%2 == 0
})
fmt.Println("Is even:", result3)
// section: usage
}Let’s break down this function with multiple type parameters:
Type Parameters:
[T any, U any]declares two separate type parametersT(input type) can beanytype - no constraintsU(output type) can beanytype - no constraints
Function Parameters:
input T- a value of typeTto transformtransformer func(T) U- a function that converts from typeTto typeU
Return Type:
U- returns a value of typeU(the transformed result)Type Relationships: The compiler ensures that the input type matches the transformer function’s input parameter, and the return type matches the transformer function’s return type
This allows us to work with different input and output types:
// Transform string to int
result1 := Transform("hello", func(s string) int {
return len(s)
})
fmt.Println("String length:", result1)
// Transform int to string
result2 := Transform(42, func(i int) string {
return fmt.Sprintf("Number: %d", i)
})
fmt.Println("Formatted number:", result2)
// Transform int to bool
result3 := Transform(10, func(i int) bool {
return i%2 == 0
})
fmt.Println("Is even:", result3)package main
import "fmt"
// section: function
func Transform[T any, U any](input T, transformer func(T) U) U {
return transformer(input)
}
// section: function
func main() {
// section: usage
// Transform string to int
result1 := Transform("hello", func(s string) int {
return len(s)
})
fmt.Println("String length:", result1)
// Transform int to string
result2 := Transform(42, func(i int) string {
return fmt.Sprintf("Number: %d", i)
})
fmt.Println("Formatted number:", result2)
// Transform int to bool
result3 := Transform(10, func(i int) bool {
return i%2 == 0
})
fmt.Println("Is even:", result3)
// section: usage
}Notice how the function works seamlessly with different type transformations:
- First Call:
Transform("hello", func(s string) int { return len(s) })- transforms string to int (length) - Second Call:
Transform(42, func(i int) string { return fmt.Sprintf("Number: %d", i) })- transforms int to string (formatting) - Third Call:
Transform(10, func(i int) bool { return i%2 == 0 })- transforms int to bool (even check) - Type Safety: Each call returns the correct output type without any type assertions
- Flexibility: One function handles multiple input-output type combinations
So far we’ve been using any as our constraint, which accepts any type. But what if we want to be more specific about which types are allowed?
Defining Custom Constraints
We can create custom constraints to be more specific about which types are allowed:
package main
import "fmt"
// section: constraint
type Numeric interface {
int | int32 | int64 | float32 | float64
}
// section: constraint
// section: function
func Add[T Numeric](a, b T) T {
return a + b
}
func Multiply[T Numeric](a, b T) T {
return a * b
}
// section: function
func main() {
// Works with various numeric types
fmt.Println("Int addition:", Add(5, 3))
fmt.Println("Float addition:", Add(3.14, 2.86))
fmt.Println("Int multiplication:", Multiply(4, 7))
fmt.Println("Float multiplication:", Multiply(2.5, 1.5))
}package main
import "fmt"
// section: constraint
type Numeric interface {
int | int32 | int64 | float32 | float64
}
// section: constraint
// section: function
func Add[T Numeric](a, b T) T {
return a + b
}
func Multiply[T Numeric](a, b T) T {
return a * b
}
// section: function
func main() {
// Works with various numeric types
fmt.Println("Int addition:", Add(5, 3))
fmt.Println("Float addition:", Add(3.14, 2.86))
fmt.Println("Int multiplication:", Multiply(4, 7))
fmt.Println("Float multiplication:", Multiply(2.5, 1.5))
}Union Type Constraints
We can use the | operator to create union constraints that allow multiple types:
package main
import "fmt"
// section: constraint
type StringOrInt interface {
string | int
}
// section: constraint
func main() {
// section: usage
process := func[T StringOrInt](input T) {
switch v := any(input).(type) {
case string:
fmt.Printf("Processing string: %s (length: %d)\n", v, len(v))
case int:
fmt.Printf("Processing int: %d (doubled: %d)\n", v, v*2)
}
}
process("hello")
process(42)
// section: usage
}
process := func[T StringOrInt](input T) {
switch v := any(input).(type) {
case string:
fmt.Printf("Processing string: %s (length: %d)\n", v, len(v))
case int:
fmt.Printf("Processing int: %d (doubled: %d)\n", v, v*2)
}
}
process("hello")
process(42)package main
import "fmt"
// section: constraint
type StringOrInt interface {
string | int
}
// section: constraint
func main() {
// section: usage
process := func[T StringOrInt](input T) {
switch v := any(input).(type) {
case string:
fmt.Printf("Processing string: %s (length: %d)\n", v, len(v))
case int:
fmt.Printf("Processing int: %d (doubled: %d)\n", v, v*2)
}
}
process("hello")
process(42)
// section: usage
}Type Assertions With Union Constraints
When working with union constraints, you might need to handle each type differently within your generic function. Notice in the example above how we use a type switch to determine which specific type we’re working with:
switch v := any(input).(type) {
case string:
// Handle string specifically
case int:
// Handle int specifically
}
Why Type Assertions with Generics?
Type-Specific Operations: When different types in your constraint need different handling
- Strings might need length checks
- Numbers might need arithmetic operations
- Custom types might need method calls
Optimization: You can provide optimized code paths for specific types
- Fast path for built-in types
- Special handling for custom types
Behavior Customization: Different types can have completely different behaviors
- Formatting output differently based on type
- Validation rules that differ by type
Important Considerations:
- The Conversion:
any(input).(type)converts the generic type toanyso we can use a type switch - Type Safety Still Applies: The type switch can only match types allowed by the constraint
- Manual Coverage Required: You must explicitly handle all types in the constraint or include a default case - Go does not perform exhaustiveness checking on type switches
- When Not to Use: If all types can be handled the same way, avoid type switching - it defeats the purpose of generics
Alternative Pattern - Using the Type Directly:
For operations that work across all types in your constraint, you don’t need type assertions:
func Double[T StringOrInt](input T) string {
// This works because both string and int can be formatted
return fmt.Sprintf("%v%v", input, input)
}
Type Safety With Constraints
One of the key benefits of generics with constraints is that the compiler enforces type safety at compile time. This prevents runtime errors that could occur with empty interfaces.
Given this function:
package main
import "fmt"
// section: constraint
type Number interface {
int | float64
}
func Add[T Number](a, b T) T {
return a + b
}
// section: constraint
// section: violation
func main() {
// This works fine - int satisfies Number constraint
result1 := Add(5, 3)
fmt.Println("5 + 3 =", result1)
// This works fine - float64 satisfies Number constraint
result2 := Add(2.5, 1.3)
fmt.Println("2.5 + 1.3 =", result2)
// This will cause a compile error - string doesn't satisfy Number constraint
result3 := Add("hello", "world")
fmt.Println("Concatenation:", result3)
}
// section: violation
// section: error
// ./main.go:23:19: string does not satisfy Number (string not in int | float64)
// section: errorLet’s see what happens when we try to use a type that doesn’t satisfy our constraint:
func main() {
// This works fine - int satisfies Number constraint
result1 := Add(5, 3)
fmt.Println("5 + 3 =", result1)
// This works fine - float64 satisfies Number constraint
result2 := Add(2.5, 1.3)
fmt.Println("2.5 + 1.3 =", result2)
// This will cause a compile error - string doesn't satisfy Number constraint
result3 := Add("hello", "world")
fmt.Println("Concatenation:", result3)
}
package main
import "fmt"
// section: constraint
type Number interface {
int | float64
}
func Add[T Number](a, b T) T {
return a + b
}
// section: constraint
// section: violation
func main() {
// This works fine - int satisfies Number constraint
result1 := Add(5, 3)
fmt.Println("5 + 3 =", result1)
// This works fine - float64 satisfies Number constraint
result2 := Add(2.5, 1.3)
fmt.Println("2.5 + 1.3 =", result2)
// This will cause a compile error - string doesn't satisfy Number constraint
result3 := Add("hello", "world")
fmt.Println("Concatenation:", result3)
}
// section: violation
// section: error
// ./main.go:23:19: string does not satisfy Number (string not in int | float64)
// section: errorHere’s what’s happening in this example:
- Valid Constraint:
StringTypesatisfies the~stringconstraint because its underlying type isstring - Invalid Constraint:
IntTypedoes not satisfy the~stringconstraint because its underlying type isint, notstring - Function Call: We try to call
processStringwith both types
The compiler will catch this error and prevent the code from compiling:
package main
import "fmt"
// section: constraint
type Number interface {
int | float64
}
func Add[T Number](a, b T) T {
return a + b
}
// section: constraint
// section: violation
func main() {
// This works fine - int satisfies Number constraint
result1 := Add(5, 3)
fmt.Println("5 + 3 =", result1)
// This works fine - float64 satisfies Number constraint
result2 := Add(2.5, 1.3)
fmt.Println("2.5 + 1.3 =", result2)
// This will cause a compile error - string doesn't satisfy Number constraint
result3 := Add("hello", "world")
fmt.Println("Concatenation:", result3)
}
// section: violation
// section: error
// ./main.go:23:19: string does not satisfy Number (string not in int | float64)
// section: errorKey benefits of compile-time constraint checking:
- Early Detection: Errors are caught at compile time, not runtime
- Clear Error Messages: The compiler tells us exactly what’s wrong and why
- No Runtime Panics: Unlike with empty interfaces, invalid types can’t cause runtime crashes
- Better IDE Support: IDEs can provide better autocompletion and error highlighting
- Confidence: You know if your code compiles, the type constraints are satisfied
This is much better than runtime panics you might get with empty interfaces, where errors only surface when the code is executed and could crash your running application.
Underlying Type Constraints
The ~ operator allows us to accept types based on a specific underlying type:
package main
import "fmt"
// section: types
type UserID int
type ProductID int
type Score float64
// section: types
// section: constraint
type IntegerID interface {
~int
}
type FloatScore interface {
~float64
}
// section: constraint
func main() {
// section: usage
processID := func[T IntegerID](id T) {
fmt.Printf("Processing ID: %v\n", id)
}
processScore := func[T FloatScore](score T) {
fmt.Printf("Processing score: %.2f\n", score)
}
var userID UserID = 123
var productID ProductID = 456
var score Score = 95.5
processID(userID)
processID(productID)
processScore(score)
// section: usage
}Here we define custom types that have int and float64 as their underlying types. Without generics, we’d need separate functions for each type.
package main
import "fmt"
// section: types
type UserID int
type ProductID int
type Score float64
// section: types
// section: constraint
type IntegerID interface {
~int
}
type FloatScore interface {
~float64
}
// section: constraint
func main() {
// section: usage
processID := func[T IntegerID](id T) {
fmt.Printf("Processing ID: %v\n", id)
}
processScore := func[T FloatScore](score T) {
fmt.Printf("Processing score: %.2f\n", score)
}
var userID UserID = 123
var productID ProductID = 456
var score Score = 95.5
processID(userID)
processID(productID)
processScore(score)
// section: usage
}Let’s understand what these constraints do:
~intConstraint: The~(tilde) operator means “any type whose underlying type isint”- This includes
intitself, but also custom types likeUserIDandProductID - Without
~, the constraint would only accept exactlyint, not custom types based onint
- This includes
~float64Constraint: Similarly, this accepts any type whose underlying type isfloat64- This includes
float64itself and custom types likeScore
- This includes
processID := func[T IntegerID](id T) {
fmt.Printf("Processing ID: %v\n", id)
}
processScore := func[T FloatScore](score T) {
fmt.Printf("Processing score: %.2f\n", score)
}
var userID UserID = 123
var productID ProductID = 456
var score Score = 95.5
processID(userID)
processID(productID)
processScore(score)package main
import "fmt"
// section: types
type UserID int
type ProductID int
type Score float64
// section: types
// section: constraint
type IntegerID interface {
~int
}
type FloatScore interface {
~float64
}
// section: constraint
func main() {
// section: usage
processID := func[T IntegerID](id T) {
fmt.Printf("Processing ID: %v\n", id)
}
processScore := func[T FloatScore](score T) {
fmt.Printf("Processing score: %.2f\n", score)
}
var userID UserID = 123
var productID ProductID = 456
var score Score = 95.5
processID(userID)
processID(productID)
processScore(score)
// section: usage
}Notice what happens in the usage:
- Type Flexibility: Both
UserIDandProductIDcan be used withprocessIDbecause they both haveintas their underlying type - Type Safety: We still maintain type safety - we can’t accidentally pass a
UserIDwhere aProductIDis expected in our business logic - Code Reuse: One generic function works with multiple related types instead of needing separate functions for each type
This is particularly useful when you have domain-specific types that share common operations but need to remain distinct types in your API.
The Constraints Package
Go provides a golang.org/x/exp/constraints package with predefined constraints for common use cases:
package main
import (
"fmt"
// section: import
"golang.org/x/exp/constraints"
// section: import
)
// section: usage
// Using constraints.Signed for signed integers
func max[T constraints.Signed](a, b T) T {
if a > b {
return a
}
return b
}
// Using constraints.Unsigned for unsigned integers
func min[T constraints.Unsigned](a, b T) T {
if a < b {
return a
}
return b
}
// Using constraints.Float for floating point numbers
func average[T constraints.Float](numbers ...T) T {
var sum T
for _, num := range numbers {
sum += num
}
return sum / T(len(numbers))
}
// section: usage
func main() {
// section: usage
fmt.Println("Max of -5 and 10:", max(-5, 10))
fmt.Println("Min of 3 and 7:", min(uint(3), uint(7)))
fmt.Println("Average of 1.5, 2.5, 3.5:", average(1.5, 2.5, 3.5))
// section: usage
}
// Using constraints.Signed for signed integers
func max[T constraints.Signed](a, b T) T {
if a > b {
return a
}
return b
}
// Using constraints.Unsigned for unsigned integers
func min[T constraints.Unsigned](a, b T) T {
if a < b {
return a
}
return b
}
// Using constraints.Float for floating point numbers
func average[T constraints.Float](numbers ...T) T {
var sum T
for _, num := range numbers {
sum += num
}
return sum / T(len(numbers))
}
fmt.Println("Max of -5 and 10:", max(-5, 10))
fmt.Println("Min of 3 and 7:", min(uint(3), uint(7)))
fmt.Println("Average of 1.5, 2.5, 3.5:", average(1.5, 2.5, 3.5))package main
import (
"fmt"
// section: import
"golang.org/x/exp/constraints"
// section: import
)
// section: usage
// Using constraints.Signed for signed integers
func max[T constraints.Signed](a, b T) T {
if a > b {
return a
}
return b
}
// Using constraints.Unsigned for unsigned integers
func min[T constraints.Unsigned](a, b T) T {
if a < b {
return a
}
return b
}
// Using constraints.Float for floating point numbers
func average[T constraints.Float](numbers ...T) T {
var sum T
for _, num := range numbers {
sum += num
}
return sum / T(len(numbers))
}
// section: usage
func main() {
// section: usage
fmt.Println("Max of -5 and 10:", max(-5, 10))
fmt.Println("Min of 3 and 7:", min(uint(3), uint(7)))
fmt.Println("Average of 1.5, 2.5, 3.5:", average(1.5, 2.5, 3.5))
// section: usage
}Common Predefined Constraints
The constraints package includes several useful predefined constraints:
// Signed integer types
constraints.Signed
// Unsigned integer types
constraints.Unsigned
// All integer types
constraints.Integer
// All floating point types
constraints.Float
// All ordered types (comparable)
constraints.Ordered
Ordered Constraint
The Ordered constraint is particularly useful for sorting and comparison operations. It allows us to write generic functions that work with any type that supports comparison operators (<, <=, >=, >).
What is Ordered?
The Ordered constraint is defined in the constraints package:
// Ordered is a constraint that permits any ordered type: any type
// that supports the operators < <= >= >.
// If future releases of Go add new ordered types,
// this constraint will be modified to include them.
type Ordered interface {
Integer | Float | ~string
}
This constraint includes:
- All integer types (int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64)
- All floating-point types (float32, float64)
- String types (string and custom types with string as underlying type)
Generic Functions With Ordered
Let’s see how we can use this constraint to create generic sorting and comparison functions:
func Sort[T constraints.Ordered](slice []T) []T {
// Simple bubble sort for demonstration
result := make([]T, len(slice))
copy(result, slice)
for i := 0; i < len(result); i++ {
for j := 0; j < len(result)-1-i; j++ {
if result[j] > result[j+1] {
result[j], result[j+1] = result[j+1], result[j]
}
}
}
return result
}
func Min[T constraints.Ordered](a, b T) T {
if a < b {
return a
}
return b
}
package main
import (
"fmt"
"golang.org/x/exp/constraints"
)
// section: function
func Sort[T constraints.Ordered](slice []T) []T {
// Simple bubble sort for demonstration
result := make([]T, len(slice))
copy(result, slice)
for i := 0; i < len(result); i++ {
for j := 0; j < len(result)-1-i; j++ {
if result[j] > result[j+1] {
result[j], result[j+1] = result[j+1], result[j]
}
}
}
return result
}
func Min[T constraints.Ordered](a, b T) T {
if a < b {
return a
}
return b
}
// section: function
func main() {
// section: usage
ints := []int{3, 1, 4, 1, 5, 9, 2, 6}
strings := []string{"banana", "apple", "cherry", "date"}
floats := []float64{3.14, 2.71, 1.41, 1.73}
fmt.Println("Original ints:", ints)
fmt.Println("Sorted ints:", Sort(ints))
fmt.Println("Original strings:", strings)
fmt.Println("Sorted strings:", Sort(strings))
fmt.Println("Original floats:", floats)
fmt.Println("Sorted floats:", Sort(floats))
fmt.Println("Min of 10 and 5:", Min(10, 5))
fmt.Println("Min of 'apple' and 'banana':", Min("apple", "banana"))
// section: usage
}Key benefits of these generic functions:
Sort Function: Uses bubble sort algorithm but works with any ordered type
- The
T constraints.Orderedparameter ensuresTcan be compared with>operator - Creates a copy of the input slice to avoid modifying the original
- Uses the same sorting logic regardless of the specific type
- The
Min Function: Finds the minimum of two values of any ordered type
- The comparison
a < bworks becauseTis constrained toOrdered - Returns the same type that was passed in, maintaining type safety
- The comparison
Using Ordered Functions
Here’s how these generic functions work with different ordered types:
ints := []int{3, 1, 4, 1, 5, 9, 2, 6}
strings := []string{"banana", "apple", "cherry", "date"}
floats := []float64{3.14, 2.71, 1.41, 1.73}
fmt.Println("Original ints:", ints)
fmt.Println("Sorted ints:", Sort(ints))
fmt.Println("Original strings:", strings)
fmt.Println("Sorted strings:", Sort(strings))
fmt.Println("Original floats:", floats)
fmt.Println("Sorted floats:", Sort(floats))
fmt.Println("Min of 10 and 5:", Min(10, 5))
fmt.Println("Min of 'apple' and 'banana':", Min("apple", "banana"))package main
import (
"fmt"
"golang.org/x/exp/constraints"
)
// section: function
func Sort[T constraints.Ordered](slice []T) []T {
// Simple bubble sort for demonstration
result := make([]T, len(slice))
copy(result, slice)
for i := 0; i < len(result); i++ {
for j := 0; j < len(result)-1-i; j++ {
if result[j] > result[j+1] {
result[j], result[j+1] = result[j+1], result[j]
}
}
}
return result
}
func Min[T constraints.Ordered](a, b T) T {
if a < b {
return a
}
return b
}
// section: function
func main() {
// section: usage
ints := []int{3, 1, 4, 1, 5, 9, 2, 6}
strings := []string{"banana", "apple", "cherry", "date"}
floats := []float64{3.14, 2.71, 1.41, 1.73}
fmt.Println("Original ints:", ints)
fmt.Println("Sorted ints:", Sort(ints))
fmt.Println("Original strings:", strings)
fmt.Println("Sorted strings:", Sort(strings))
fmt.Println("Original floats:", floats)
fmt.Println("Sorted floats:", Sort(floats))
fmt.Println("Min of 10 and 5:", Min(10, 5))
fmt.Println("Min of 'apple' and 'banana':", Min("apple", "banana"))
// section: usage
}Notice what happens in this example:
- Type Flexibility: The same
Sortfunction works with[]int,[]string, and[]float64 - Type Safety: Each function call returns the same type that was passed in
- No Type Conversion: We don’t need to convert types or write separate functions for each type
- Efficient: No runtime type checking or assertions - all type safety is verified at compile time
This demonstrates the power of the Ordered constraint for creating reusable algorithms that work across multiple numeric and string types while maintaining full type safety.
Exercise (20 Mins)
Create a generic Filter function that takes a slice and a predicate function, returning a new slice with only the elements that satisfy the predicate.
The function should work with any type and look like this:
func Filter[T any](slice []T, predicate func(T) bool) []T
Test it with:
- A slice of integers, filtering for even numbers
- A slice of strings, filtering for strings longer than 3 characters
package main
import "fmt"
// TODO: Implement the Filter function using generics
func main() {
// Test with integers - filter even numbers
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
evens := Filter(numbers, func(n int) bool {
return n%2 == 0
})
fmt.Println("Even numbers:", evens)
// Test with strings - filter strings longer than 3 characters
words := []string{"go", "rust", "python", "c", "javascript", "lua"}
longWords := Filter(words, func(s string) bool {
return len(s) > 3
})
fmt.Println("Long words:", longWords)
// Test with floats - filter numbers greater than 5.0
floats := []float64{1.1, 3.3, 5.5, 7.7, 2.2, 9.9}
bigFloats := Filter(floats, func(f float64) bool {
return f > 5.0
})
fmt.Println("Big floats:", bigFloats)
}package main
import "fmt"
// TODO: Implement the Filter function using generics
func main() {
// Test with integers - filter even numbers
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
evens := Filter(numbers, func(n int) bool {
return n%2 == 0
})
fmt.Println("Even numbers:", evens)
// Test with strings - filter strings longer than 3 characters
words := []string{"go", "rust", "python", "c", "javascript", "lua"}
longWords := Filter(words, func(s string) bool {
return len(s) > 3
})
fmt.Println("Long words:", longWords)
// Test with floats - filter numbers greater than 5.0
floats := []float64{1.1, 3.3, 5.5, 7.7, 2.2, 9.9}
bigFloats := Filter(floats, func(f float64) bool {
return f > 5.0
})
fmt.Println("Big floats:", bigFloats)
}Exercise Solution
The solution demonstrates key generic programming concepts:
- Generic Function Declaration:
Filter[T any]accepts any type - Type Parameter Usage: Uses
Tin both parameter and return types - Type Safety: Maintains type throughout the function
- Flexibility: Works with any type while preserving type information
package main
import "fmt"
// Filter takes a slice and a predicate function, returning a new slice
// with only the elements that satisfy the predicate
func Filter[T any](slice []T, predicate func(T) bool) []T {
var result []T
for _, item := range slice {
if predicate(item) {
result = append(result, item)
}
}
return result
}
func main() {
// Test with integers - filter even numbers
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
evens := Filter(numbers, func(n int) bool {
return n%2 == 0
})
fmt.Println("Even numbers:", evens)
// Test with strings - filter strings longer than 3 characters
words := []string{"go", "rust", "python", "c", "javascript", "lua"}
longWords := Filter(words, func(s string) bool {
return len(s) > 3
})
fmt.Println("Long words:", longWords)
// Test with floats - filter numbers greater than 5.0
floats := []float64{1.1, 3.3, 5.5, 7.7, 2.2, 9.9}
bigFloats := Filter(floats, func(f float64) bool {
return f > 5.0
})
fmt.Println("Big floats:", bigFloats)
}package main
import "fmt"
// Filter takes a slice and a predicate function, returning a new slice
// with only the elements that satisfy the predicate
func Filter[T any](slice []T, predicate func(T) bool) []T {
var result []T
for _, item := range slice {
if predicate(item) {
result = append(result, item)
}
}
return result
}
func main() {
// Test with integers - filter even numbers
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
evens := Filter(numbers, func(n int) bool {
return n%2 == 0
})
fmt.Println("Even numbers:", evens)
// Test with strings - filter strings longer than 3 characters
words := []string{"go", "rust", "python", "c", "javascript", "lua"}
longWords := Filter(words, func(s string) bool {
return len(s) > 3
})
fmt.Println("Long words:", longWords)
// Test with floats - filter numbers greater than 5.0
floats := []float64{1.1, 3.3, 5.5, 7.7, 2.2, 9.9}
bigFloats := Filter(floats, func(f float64) bool {
return f > 5.0
})
fmt.Println("Big floats:", bigFloats)
}Key Learning Points:
- How to declare generic functions with type parameters
- Using
anyconstraint for maximum flexibility - Maintaining type safety across function boundaries
- Working with higher-order functions (predicates) in generic contexts
Generic Data Structures
We can also create generic types, not just functions:
type Stack[T any] struct {
items []T
}
func NewStack[T any]() *Stack[T] {
return &Stack[T]{
items: make([]T, 0),
}
}
package main
import "fmt"
// section: definition
type Stack[T any] struct {
items []T
}
func NewStack[T any]() *Stack[T] {
return &Stack[T]{
items: make([]T, 0),
}
}
// section: definition
// section: methods
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
}
index := len(s.items) - 1
item := s.items[index]
clear(s.items[index:])
s.items = s.items[:index]
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]) Size() int {
return len(s.items)
}
func (s *Stack[T]) IsEmpty() bool {
return len(s.items) == 0
}
// section: methods
func main() {
// section: usage
// Stack of integers
intStack := NewStack[int]()
intStack.Push(1)
intStack.Push(2)
intStack.Push(3)
fmt.Println("Int stack size:", intStack.Size())
if value, ok := intStack.Pop(); ok {
fmt.Println("Popped from int stack:", value)
}
if value, ok := intStack.Peek(); ok {
fmt.Println("Peeked at int stack:", value)
}
// Stack of strings
stringStack := NewStack[string]()
stringStack.Push("hello")
stringStack.Push("world")
fmt.Println("String stack size:", stringStack.Size())
for !stringStack.IsEmpty() {
if value, ok := stringStack.Pop(); ok {
fmt.Println("Popped from string stack:", value)
}
}
// section: usage
}Let’s break down the generic type definition:
- Generic Type Declaration:
Stack[T any]defines a generic type with type parameterT - Type Parameter Usage: The
itemsfield uses[]T, meaning it’s a slice of whatever typeTrepresents - Constructor Function:
NewStack[T any]()creates a new stack instance - Memory Allocation: We initialize the slice with
make([]T, 0)for efficient memory usage
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
}
index := len(s.items) - 1
item := s.items[index]
clear(s.items[index:])
s.items = s.items[:index]
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]) Size() int {
return len(s.items)
}
func (s *Stack[T]) IsEmpty() bool {
return len(s.items) == 0
}
package main
import "fmt"
// section: definition
type Stack[T any] struct {
items []T
}
func NewStack[T any]() *Stack[T] {
return &Stack[T]{
items: make([]T, 0),
}
}
// section: definition
// section: methods
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
}
index := len(s.items) - 1
item := s.items[index]
clear(s.items[index:])
s.items = s.items[:index]
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]) Size() int {
return len(s.items)
}
func (s *Stack[T]) IsEmpty() bool {
return len(s.items) == 0
}
// section: methods
func main() {
// section: usage
// Stack of integers
intStack := NewStack[int]()
intStack.Push(1)
intStack.Push(2)
intStack.Push(3)
fmt.Println("Int stack size:", intStack.Size())
if value, ok := intStack.Pop(); ok {
fmt.Println("Popped from int stack:", value)
}
if value, ok := intStack.Peek(); ok {
fmt.Println("Peeked at int stack:", value)
}
// Stack of strings
stringStack := NewStack[string]()
stringStack.Push("hello")
stringStack.Push("world")
fmt.Println("String stack size:", stringStack.Size())
for !stringStack.IsEmpty() {
if value, ok := stringStack.Pop(); ok {
fmt.Println("Popped from string stack:", value)
}
}
// section: usage
}Now let’s examine the methods:
- Push Method:
Push(item T)accepts an item of typeTand adds it to the stack - Pop Method: Returns
(T, bool)- the item and whether the operation was successful- Uses
var zero Tto create a zero value of typeTwhen the stack is empty - Safely removes the last item using slice operations
- Uses
- Peek Method: Returns the top item without removing it
- Utility Methods:
Size()andIsEmpty()provide convenient stack information
The key advantage is that all these methods work with the specific type T that was chosen when creating the stack instance, providing full type safety.
Using Generic Types
// Stack of integers
intStack := NewStack[int]()
intStack.Push(1)
intStack.Push(2)
intStack.Push(3)
fmt.Println("Int stack size:", intStack.Size())
if value, ok := intStack.Pop(); ok {
fmt.Println("Popped from int stack:", value)
}
if value, ok := intStack.Peek(); ok {
fmt.Println("Peeked at int stack:", value)
}
// Stack of strings
stringStack := NewStack[string]()
stringStack.Push("hello")
stringStack.Push("world")
fmt.Println("String stack size:", stringStack.Size())
for !stringStack.IsEmpty() {
if value, ok := stringStack.Pop(); ok {
fmt.Println("Popped from string stack:", value)
}
}package main
import "fmt"
// section: definition
type Stack[T any] struct {
items []T
}
func NewStack[T any]() *Stack[T] {
return &Stack[T]{
items: make([]T, 0),
}
}
// section: definition
// section: methods
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
}
index := len(s.items) - 1
item := s.items[index]
clear(s.items[index:])
s.items = s.items[:index]
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]) Size() int {
return len(s.items)
}
func (s *Stack[T]) IsEmpty() bool {
return len(s.items) == 0
}
// section: methods
func main() {
// section: usage
// Stack of integers
intStack := NewStack[int]()
intStack.Push(1)
intStack.Push(2)
intStack.Push(3)
fmt.Println("Int stack size:", intStack.Size())
if value, ok := intStack.Pop(); ok {
fmt.Println("Popped from int stack:", value)
}
if value, ok := intStack.Peek(); ok {
fmt.Println("Peeked at int stack:", value)
}
// Stack of strings
stringStack := NewStack[string]()
stringStack.Push("hello")
stringStack.Push("world")
fmt.Println("String stack size:", stringStack.Size())
for !stringStack.IsEmpty() {
if value, ok := stringStack.Pop(); ok {
fmt.Println("Popped from string stack:", value)
}
}
// section: usage
}Let’s examine how we use the generic stack:
Type Instantiation:
NewStack[int]()creates a stack specifically for integersNewStack[string]()creates a stack specifically for strings- The type parameter
[int]and[string]determine what type the stack will store
Type Safety:
- The integer stack can only store integers
- The string stack can only store strings
- Trying to push a string onto the integer stack would be a compile-time error
Method Usage:
Push()adds items to the stackPop()returns(value, ok)whereokindicates successPeek()lets us see the top item without removing itSize()andIsEmpty()provide stack state information
Benefits:
- No Type Casting: We get back the exact type we put in
- Compile-Time Safety: The compiler ensures type correctness
- Performance: No runtime type assertions or boxing/unboxing
- Code Reuse: One stack implementation works for any type
Generic Type Aliases
Go 1.24 introduced full support for generic type aliases. Type aliases with type parameters allow you to create convenient shorthand names for generic types or specific instantiations of generic types.
Simple Type Aliases
The simplest form of generic type alias creates a convenient name for a specific instantiation of a generic type:
// Simple type alias - creates a convenient name for a specific instantiation
type IntStack = Stack[int]
type StringStack = Stack[string]
package main
import "fmt"
// section: stack
type Stack[T any] struct {
items []T
}
func NewStack[T any]() *Stack[T] {
return &Stack[T]{
items: make([]T, 0),
}
}
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
}
index := len(s.items) - 1
item := s.items[index]
s.items = s.items[:index]
return item, true
}
// section: stack
// section: simple-alias
// Simple type alias - creates a convenient name for a specific instantiation
type IntStack = Stack[int]
type StringStack = Stack[string]
// section: simple-alias
// section: parameterized-alias
// Parameterized type alias - the alias itself takes type parameters
type StringMap[V any] = map[string]V
type Pair[T, U any] = struct {
First T
Second U
}
type Result[T any] = struct {
Value T
Err error
}
// section: parameterized-alias
// section: invalid-alias
// Invalid: alias cannot be a type parameter itself
// type Identity[T any] = T // compile error: cannot use a type parameter as RHS in type declaration
// section: invalid-alias
// section: usage
func main() {
// Using simple type aliases
intStack := new(IntStack)
intStack.Push(1)
intStack.Push(2)
intStack.Push(3)
if val, ok := intStack.Pop(); ok {
fmt.Println("Popped from IntStack:", val)
}
// Using parameterized type aliases
ages := StringMap[int]{
"Alice": 30,
"Bob": 25,
}
fmt.Println("Ages:", ages)
pair := Pair[string, int]{
First: "count",
Second: 42,
}
fmt.Println("Pair:", pair)
result := Result[string]{
Value: "success",
Err: nil,
}
fmt.Println("Result:", result.Value)
}
// section: usageThis is useful when you frequently use a generic type with specific type parameters. Instead of writing Stack[int] everywhere, you can use IntStack.
Parameterized Type Aliases
More powerful is the ability to create aliases that themselves take type parameters:
// Parameterized type alias - the alias itself takes type parameters
type StringMap[V any] = map[string]V
type Pair[T, U any] = struct {
First T
Second U
}
type Result[T any] = struct {
Value T
Err error
}
package main
import "fmt"
// section: stack
type Stack[T any] struct {
items []T
}
func NewStack[T any]() *Stack[T] {
return &Stack[T]{
items: make([]T, 0),
}
}
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
}
index := len(s.items) - 1
item := s.items[index]
s.items = s.items[:index]
return item, true
}
// section: stack
// section: simple-alias
// Simple type alias - creates a convenient name for a specific instantiation
type IntStack = Stack[int]
type StringStack = Stack[string]
// section: simple-alias
// section: parameterized-alias
// Parameterized type alias - the alias itself takes type parameters
type StringMap[V any] = map[string]V
type Pair[T, U any] = struct {
First T
Second U
}
type Result[T any] = struct {
Value T
Err error
}
// section: parameterized-alias
// section: invalid-alias
// Invalid: alias cannot be a type parameter itself
// type Identity[T any] = T // compile error: cannot use a type parameter as RHS in type declaration
// section: invalid-alias
// section: usage
func main() {
// Using simple type aliases
intStack := new(IntStack)
intStack.Push(1)
intStack.Push(2)
intStack.Push(3)
if val, ok := intStack.Pop(); ok {
fmt.Println("Popped from IntStack:", val)
}
// Using parameterized type aliases
ages := StringMap[int]{
"Alice": 30,
"Bob": 25,
}
fmt.Println("Ages:", ages)
pair := Pair[string, int]{
First: "count",
Second: 42,
}
fmt.Println("Pair:", pair)
result := Result[string]{
Value: "success",
Err: nil,
}
fmt.Println("Result:", result.Value)
}
// section: usageLet’s understand each alias:
StringMap[V any]: Creates a map type with string keys and any value type- Usage:
StringMap[int]becomesmap[string]int
- Usage:
Pair[T, U any]: Creates a generic pair type- Usage:
Pair[string, int]becomes a struct withFirst stringandSecond int
- Usage:
Result[T any]: Creates a result wrapper for any type- Common pattern for operations that may fail
Restrictions On Type Aliases
There’s an important restriction: the alias cannot be a type parameter itself:
// Invalid: alias cannot be a type parameter itself
// type Identity[T any] = T // compile error: cannot use a type parameter as RHS in type declaration
package main
import "fmt"
// section: stack
type Stack[T any] struct {
items []T
}
func NewStack[T any]() *Stack[T] {
return &Stack[T]{
items: make([]T, 0),
}
}
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
}
index := len(s.items) - 1
item := s.items[index]
s.items = s.items[:index]
return item, true
}
// section: stack
// section: simple-alias
// Simple type alias - creates a convenient name for a specific instantiation
type IntStack = Stack[int]
type StringStack = Stack[string]
// section: simple-alias
// section: parameterized-alias
// Parameterized type alias - the alias itself takes type parameters
type StringMap[V any] = map[string]V
type Pair[T, U any] = struct {
First T
Second U
}
type Result[T any] = struct {
Value T
Err error
}
// section: parameterized-alias
// section: invalid-alias
// Invalid: alias cannot be a type parameter itself
// type Identity[T any] = T // compile error: cannot use a type parameter as RHS in type declaration
// section: invalid-alias
// section: usage
func main() {
// Using simple type aliases
intStack := new(IntStack)
intStack.Push(1)
intStack.Push(2)
intStack.Push(3)
if val, ok := intStack.Pop(); ok {
fmt.Println("Popped from IntStack:", val)
}
// Using parameterized type aliases
ages := StringMap[int]{
"Alice": 30,
"Bob": 25,
}
fmt.Println("Ages:", ages)
pair := Pair[string, int]{
First: "count",
Second: 42,
}
fmt.Println("Pair:", pair)
result := Result[string]{
Value: "success",
Err: nil,
}
fmt.Println("Result:", result.Value)
}
// section: usageThis means you can’t create an “identity” type alias that just passes through the type parameter.
Using Generic Type Aliases
Here’s how to use both simple and parameterized type aliases:
func main() {
// Using simple type aliases
intStack := new(IntStack)
intStack.Push(1)
intStack.Push(2)
intStack.Push(3)
if val, ok := intStack.Pop(); ok {
fmt.Println("Popped from IntStack:", val)
}
// Using parameterized type aliases
ages := StringMap[int]{
"Alice": 30,
"Bob": 25,
}
fmt.Println("Ages:", ages)
pair := Pair[string, int]{
First: "count",
Second: 42,
}
fmt.Println("Pair:", pair)
result := Result[string]{
Value: "success",
Err: nil,
}
fmt.Println("Result:", result.Value)
}
package main
import "fmt"
// section: stack
type Stack[T any] struct {
items []T
}
func NewStack[T any]() *Stack[T] {
return &Stack[T]{
items: make([]T, 0),
}
}
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
}
index := len(s.items) - 1
item := s.items[index]
s.items = s.items[:index]
return item, true
}
// section: stack
// section: simple-alias
// Simple type alias - creates a convenient name for a specific instantiation
type IntStack = Stack[int]
type StringStack = Stack[string]
// section: simple-alias
// section: parameterized-alias
// Parameterized type alias - the alias itself takes type parameters
type StringMap[V any] = map[string]V
type Pair[T, U any] = struct {
First T
Second U
}
type Result[T any] = struct {
Value T
Err error
}
// section: parameterized-alias
// section: invalid-alias
// Invalid: alias cannot be a type parameter itself
// type Identity[T any] = T // compile error: cannot use a type parameter as RHS in type declaration
// section: invalid-alias
// section: usage
func main() {
// Using simple type aliases
intStack := new(IntStack)
intStack.Push(1)
intStack.Push(2)
intStack.Push(3)
if val, ok := intStack.Pop(); ok {
fmt.Println("Popped from IntStack:", val)
}
// Using parameterized type aliases
ages := StringMap[int]{
"Alice": 30,
"Bob": 25,
}
fmt.Println("Ages:", ages)
pair := Pair[string, int]{
First: "count",
Second: 42,
}
fmt.Println("Pair:", pair)
result := Result[string]{
Value: "success",
Err: nil,
}
fmt.Println("Result:", result.Value)
}
// section: usageKey points about generic type aliases:
- Instantiation Required: Parameterized type aliases must be instantiated when used (e.g.,
StringMap[int], not justStringMap) - Type Equivalence: An alias is exactly equivalent to its underlying type -
IntStackis the same type asStack[int] - No New Methods: You cannot add methods to a type alias; use a type definition instead if you need to add methods
- Documentation: Aliases can make code more readable by giving meaningful names to complex type expressions
Type Alias Vs Type Definition
When should you use a type alias versus a type definition?
Use a Type Alias (=) when:
- You want a shorthand name for a complex type
- You want interchangeability with the original type
- You’re creating API compatibility shims
- You don’t need to add new methods
// Type alias - IntStack and Stack[int] are the SAME type
type IntStack = Stack[int]
Use a Type Definition (no =) when:
- You want a distinct new type
- You need to add methods to the type
- You want compile-time type safety between similar types
- You’re creating domain-specific types
// Type definition - UserID is a DIFFERENT type from int
type UserID int
func (id UserID) String() string { return fmt.Sprintf("user-%d", id) }
Interface With Type Parameters
We can combine interfaces with type parameters for more advanced patterns. This approach is particularly useful for building generic data access layers or repositories that can work with different types of entities while maintaining type safety.
Let’s start by defining a generic interface that represents any entity that can be identified by an ID:
type Identifiable[T comparable] interface {
ID() T
}
type Repository[T comparable, U Identifiable[T]] interface {
Save(item U) error
FindByID(id T) (U, bool)
Delete(id T) error
}
package main
import (
"fmt"
)
// section: interface
type Identifiable[T comparable] interface {
ID() T
}
type Repository[T comparable, U Identifiable[T]] interface {
Save(item U) error
FindByID(id T) (U, bool)
Delete(id T) error
}
// section: interface
// section: user
type User struct {
id int
name string
}
func (u User) ID() int {
return u.id
}
func (u User) String() string {
return fmt.Sprintf("User{id: %d, name: %s}", u.id, u.name)
}
// section: user
// section: implementation
type InMemoryRepository[T comparable, U Identifiable[T]] struct {
items map[T]U
}
func NewInMemoryRepository[T comparable, U Identifiable[T]]() *InMemoryRepository[T, U] {
return &InMemoryRepository[T, U]{
items: make(map[T]U),
}
}
func (r *InMemoryRepository[T, U]) Save(item U) error {
r.items[item.ID()] = item
return nil
}
func (r *InMemoryRepository[T, U]) FindByID(id T) (U, bool) {
item, exists := r.items[id]
return item, exists
}
func (r *InMemoryRepository[T, U]) Delete(id T) error {
delete(r.items, id)
return nil
}
// section: implementation
func main() {
// Create a repository for Users with int IDs
repo := NewInMemoryRepository[int, User]()
// Save some users
users := []User{
{id: 1, name: "Alice"},
{id: 2, name: "Bob"},
{id: 3, name: "Charlie"},
}
for _, user := range users {
repo.Save(user)
}
// Find users
if user, found := repo.FindByID(2); found {
fmt.Println("Found user:", user)
}
// Delete a user
repo.Delete(1)
// Try to find deleted user
if _, found := repo.FindByID(1); !found {
fmt.Println("User with ID 1 not found (deleted)")
}
}Let’s break down these interface definitions:
Identifiable[T comparable]: This interface represents any type that has anID()method returning a value of typeT- The type parameter
Tis constrained tocomparableso it can be used as a map key - This allows entities to have different ID types (int, string, uuid.UUID, time.Time, etc.) while maintaining type safety
- The type parameter
Repository[T comparable, U Identifiable[T]]: This interface defines a generic repository patternTrepresents the type of the ID (must be comparable for map operations)Urepresents the entity type, which must satisfyIdentifiable[T]- This ensures that whatever entity type we store, its ID type matches the repository’s ID type
Now let’s create a concrete type that satisfies the Identifiable interface:
type User struct {
id int
name string
}
func (u User) ID() int {
return u.id
}
func (u User) String() string {
return fmt.Sprintf("User{id: %d, name: %s}", u.id, u.name)
}
package main
import (
"fmt"
)
// section: interface
type Identifiable[T comparable] interface {
ID() T
}
type Repository[T comparable, U Identifiable[T]] interface {
Save(item U) error
FindByID(id T) (U, bool)
Delete(id T) error
}
// section: interface
// section: user
type User struct {
id int
name string
}
func (u User) ID() int {
return u.id
}
func (u User) String() string {
return fmt.Sprintf("User{id: %d, name: %s}", u.id, u.name)
}
// section: user
// section: implementation
type InMemoryRepository[T comparable, U Identifiable[T]] struct {
items map[T]U
}
func NewInMemoryRepository[T comparable, U Identifiable[T]]() *InMemoryRepository[T, U] {
return &InMemoryRepository[T, U]{
items: make(map[T]U),
}
}
func (r *InMemoryRepository[T, U]) Save(item U) error {
r.items[item.ID()] = item
return nil
}
func (r *InMemoryRepository[T, U]) FindByID(id T) (U, bool) {
item, exists := r.items[id]
return item, exists
}
func (r *InMemoryRepository[T, U]) Delete(id T) error {
delete(r.items, id)
return nil
}
// section: implementation
func main() {
// Create a repository for Users with int IDs
repo := NewInMemoryRepository[int, User]()
// Save some users
users := []User{
{id: 1, name: "Alice"},
{id: 2, name: "Bob"},
{id: 3, name: "Charlie"},
}
for _, user := range users {
repo.Save(user)
}
// Find users
if user, found := repo.FindByID(2); found {
fmt.Println("Found user:", user)
}
// Delete a user
repo.Delete(1)
// Try to find deleted user
if _, found := repo.FindByID(1); !found {
fmt.Println("User with ID 1 not found (deleted)")
}
}The User struct satisfies the Identifiable[int] interface because it implements the ID() method that returns an int. This allows the type system to verify that a User can be stored in a repository that expects entities with integer IDs.
Finally, let’s implement the generic repository interface:
type InMemoryRepository[T comparable, U Identifiable[T]] struct {
items map[T]U
}
func NewInMemoryRepository[T comparable, U Identifiable[T]]() *InMemoryRepository[T, U] {
return &InMemoryRepository[T, U]{
items: make(map[T]U),
}
}
func (r *InMemoryRepository[T, U]) Save(item U) error {
r.items[item.ID()] = item
return nil
}
func (r *InMemoryRepository[T, U]) FindByID(id T) (U, bool) {
item, exists := r.items[id]
return item, exists
}
func (r *InMemoryRepository[T, U]) Delete(id T) error {
delete(r.items, id)
return nil
}
package main
import (
"fmt"
)
// section: interface
type Identifiable[T comparable] interface {
ID() T
}
type Repository[T comparable, U Identifiable[T]] interface {
Save(item U) error
FindByID(id T) (U, bool)
Delete(id T) error
}
// section: interface
// section: user
type User struct {
id int
name string
}
func (u User) ID() int {
return u.id
}
func (u User) String() string {
return fmt.Sprintf("User{id: %d, name: %s}", u.id, u.name)
}
// section: user
// section: implementation
type InMemoryRepository[T comparable, U Identifiable[T]] struct {
items map[T]U
}
func NewInMemoryRepository[T comparable, U Identifiable[T]]() *InMemoryRepository[T, U] {
return &InMemoryRepository[T, U]{
items: make(map[T]U),
}
}
func (r *InMemoryRepository[T, U]) Save(item U) error {
r.items[item.ID()] = item
return nil
}
func (r *InMemoryRepository[T, U]) FindByID(id T) (U, bool) {
item, exists := r.items[id]
return item, exists
}
func (r *InMemoryRepository[T, U]) Delete(id T) error {
delete(r.items, id)
return nil
}
// section: implementation
func main() {
// Create a repository for Users with int IDs
repo := NewInMemoryRepository[int, User]()
// Save some users
users := []User{
{id: 1, name: "Alice"},
{id: 2, name: "Bob"},
{id: 3, name: "Charlie"},
}
for _, user := range users {
repo.Save(user)
}
// Find users
if user, found := repo.FindByID(2); found {
fmt.Println("Found user:", user)
}
// Delete a user
repo.Delete(1)
// Try to find deleted user
if _, found := repo.FindByID(1); !found {
fmt.Println("User with ID 1 not found (deleted)")
}
}This InMemoryRepository implementation demonstrates several key concepts:
- Generic Implementation: The repository works with any ID type
Tand any entity typeUthat satisfiesIdentifiable[T] - Type Safety: The compiler ensures that the ID type of the entity matches the repository’s ID type
- Flexibility: The same repository implementation can work with
Userentities withintIDs,Productentities withstringIDs, etc. - Interface Compliance: The repository satisfies the
Repository[T, U]interface contract
Note: This repository implementation is just an example and is not concurrent-safe. In a real application, you would add synchronization using a mutex to make it safe for concurrent access, which we cover in other chapters.
This pattern is particularly powerful because it allows you to write generic, reusable data access code while maintaining strict type safety and eliminating the need for type assertions.
Mixing Method And Type Constraints
So far we’ve seen two different kinds of constraints:
- Type Constraints: Specify which types are allowed using
|(likeint | string | float64) - Method Constraints: Specify which methods a type must implement (like interfaces)
The powerful feature of Go generics is that you can combine both in a single constraint. This allows you to restrict your generic function to specific types and require those types to implement certain methods.
Combining Type And Method Requirements
Let’s say we want to create a constraint for values that can be both ordered (for comparison) and printed with a custom format. We can define a constraint that includes both type restrictions and method requirements:
// Printable is a constraint that combines both type and method requirements
type Printable interface {
~int | ~string | ~float64 // Type constraint: must be based on one of these types
String() string // Method constraint: must implement String() method
}
package main
import "fmt"
// section: constraint
// Printable is a constraint that combines both type and method requirements
type Printable interface {
~int | ~string | ~float64 // Type constraint: must be based on one of these types
String() string // Method constraint: must implement String() method
}
// section: constraint
// section: types
// Score is based on int and implements String()
type Score int
func (s Score) String() string {
return fmt.Sprintf("Score: %d", s)
}
// Temperature is based on float64 and implements String()
type Temperature float64
func (t Temperature) String() string {
return fmt.Sprintf("%.1f°C", t)
}
// Status is based on string and implements String()
type Status string
func (s Status) String() string {
return fmt.Sprintf("Status: %s", string(s))
}
// section: types
// section: function
// Display is a generic function that accepts any Printable type
func Display[T Printable](value T) {
// We can call String() because the Printable constraint requires it
fmt.Println(value.String())
}
// Compare compares two Printable values
// Note: This works because all types in Printable constraint support comparison
func Compare[T Printable](a, b T) string {
// We can use comparison operators because underlying types are comparable
if a == b {
return fmt.Sprintf("%s equals %s", a.String(), b.String())
}
return fmt.Sprintf("%s does not equal %s", a.String(), b.String())
}
// section: function
// section: usage
func main() {
// Create instances of our custom types
score1 := Score(95)
score2 := Score(87)
temp := Temperature(23.5)
status := Status("active")
// Display using the generic Display function
fmt.Println("=== Display Examples ===")
Display(score1)
Display(temp)
Display(status)
// Compare values
fmt.Println("\n=== Comparison Examples ===")
fmt.Println(Compare(score1, score2))
fmt.Println(Compare(score1, Score(95)))
// We can also use the values in generic collections
fmt.Println("\n=== Collection Example ===")
scores := []Score{Score(85), Score(92), Score(78)}
fmt.Println("All scores:")
for _, s := range scores {
Display(s)
}
}
// section: usageLet’s break down what this constraint means:
- Type Union:
~int | ~string | ~float64specifies that the type must be based onint,string, orfloat64 - Method Requirement:
String() stringrequires that the type implements this method - Combined Effect: A type must satisfy BOTH requirements - it must be one of the specified types AND implement the
String()method
This is different from trying to use constraints.Ordered | fmt.Stringer, which would be a compilation error. Instead, we define the type union and methods together within the interface.
Using Mixed Constraints
Now let’s create custom types that satisfy our Printable constraint:
// Score is based on int and implements String()
type Score int
func (s Score) String() string {
return fmt.Sprintf("Score: %d", s)
}
// Temperature is based on float64 and implements String()
type Temperature float64
func (t Temperature) String() string {
return fmt.Sprintf("%.1f°C", t)
}
// Status is based on string and implements String()
type Status string
func (s Status) String() string {
return fmt.Sprintf("Status: %s", string(s))
}
package main
import "fmt"
// section: constraint
// Printable is a constraint that combines both type and method requirements
type Printable interface {
~int | ~string | ~float64 // Type constraint: must be based on one of these types
String() string // Method constraint: must implement String() method
}
// section: constraint
// section: types
// Score is based on int and implements String()
type Score int
func (s Score) String() string {
return fmt.Sprintf("Score: %d", s)
}
// Temperature is based on float64 and implements String()
type Temperature float64
func (t Temperature) String() string {
return fmt.Sprintf("%.1f°C", t)
}
// Status is based on string and implements String()
type Status string
func (s Status) String() string {
return fmt.Sprintf("Status: %s", string(s))
}
// section: types
// section: function
// Display is a generic function that accepts any Printable type
func Display[T Printable](value T) {
// We can call String() because the Printable constraint requires it
fmt.Println(value.String())
}
// Compare compares two Printable values
// Note: This works because all types in Printable constraint support comparison
func Compare[T Printable](a, b T) string {
// We can use comparison operators because underlying types are comparable
if a == b {
return fmt.Sprintf("%s equals %s", a.String(), b.String())
}
return fmt.Sprintf("%s does not equal %s", a.String(), b.String())
}
// section: function
// section: usage
func main() {
// Create instances of our custom types
score1 := Score(95)
score2 := Score(87)
temp := Temperature(23.5)
status := Status("active")
// Display using the generic Display function
fmt.Println("=== Display Examples ===")
Display(score1)
Display(temp)
Display(status)
// Compare values
fmt.Println("\n=== Comparison Examples ===")
fmt.Println(Compare(score1, score2))
fmt.Println(Compare(score1, Score(95)))
// We can also use the values in generic collections
fmt.Println("\n=== Collection Example ===")
scores := []Score{Score(85), Score(92), Score(78)}
fmt.Println("All scores:")
for _, s := range scores {
Display(s)
}
}
// section: usageNotice what makes these types compatible with the Printable constraint:
- Score: Based on
int(satisfies~int) and implementsString()method - Temperature: Based on
float64(satisfies~float64) and implementsString()method - Status: Based on
string(satisfies~string) and implementsString()method
Now we can write a generic function that uses our mixed constraint:
// Display is a generic function that accepts any Printable type
func Display[T Printable](value T) {
// We can call String() because the Printable constraint requires it
fmt.Println(value.String())
}
// Compare compares two Printable values
// Note: This works because all types in Printable constraint support comparison
func Compare[T Printable](a, b T) string {
// We can use comparison operators because underlying types are comparable
if a == b {
return fmt.Sprintf("%s equals %s", a.String(), b.String())
}
return fmt.Sprintf("%s does not equal %s", a.String(), b.String())
}
package main
import "fmt"
// section: constraint
// Printable is a constraint that combines both type and method requirements
type Printable interface {
~int | ~string | ~float64 // Type constraint: must be based on one of these types
String() string // Method constraint: must implement String() method
}
// section: constraint
// section: types
// Score is based on int and implements String()
type Score int
func (s Score) String() string {
return fmt.Sprintf("Score: %d", s)
}
// Temperature is based on float64 and implements String()
type Temperature float64
func (t Temperature) String() string {
return fmt.Sprintf("%.1f°C", t)
}
// Status is based on string and implements String()
type Status string
func (s Status) String() string {
return fmt.Sprintf("Status: %s", string(s))
}
// section: types
// section: function
// Display is a generic function that accepts any Printable type
func Display[T Printable](value T) {
// We can call String() because the Printable constraint requires it
fmt.Println(value.String())
}
// Compare compares two Printable values
// Note: This works because all types in Printable constraint support comparison
func Compare[T Printable](a, b T) string {
// We can use comparison operators because underlying types are comparable
if a == b {
return fmt.Sprintf("%s equals %s", a.String(), b.String())
}
return fmt.Sprintf("%s does not equal %s", a.String(), b.String())
}
// section: function
// section: usage
func main() {
// Create instances of our custom types
score1 := Score(95)
score2 := Score(87)
temp := Temperature(23.5)
status := Status("active")
// Display using the generic Display function
fmt.Println("=== Display Examples ===")
Display(score1)
Display(temp)
Display(status)
// Compare values
fmt.Println("\n=== Comparison Examples ===")
fmt.Println(Compare(score1, score2))
fmt.Println(Compare(score1, Score(95)))
// We can also use the values in generic collections
fmt.Println("\n=== Collection Example ===")
scores := []Score{Score(85), Score(92), Score(78)}
fmt.Println("All scores:")
for _, s := range scores {
Display(s)
}
}
// section: usageAnd use it with our custom types:
func main() {
// Create instances of our custom types
score1 := Score(95)
score2 := Score(87)
temp := Temperature(23.5)
status := Status("active")
// Display using the generic Display function
fmt.Println("=== Display Examples ===")
Display(score1)
Display(temp)
Display(status)
// Compare values
fmt.Println("\n=== Comparison Examples ===")
fmt.Println(Compare(score1, score2))
fmt.Println(Compare(score1, Score(95)))
// We can also use the values in generic collections
fmt.Println("\n=== Collection Example ===")
scores := []Score{Score(85), Score(92), Score(78)}
fmt.Println("All scores:")
for _, s := range scores {
Display(s)
}
}
package main
import "fmt"
// section: constraint
// Printable is a constraint that combines both type and method requirements
type Printable interface {
~int | ~string | ~float64 // Type constraint: must be based on one of these types
String() string // Method constraint: must implement String() method
}
// section: constraint
// section: types
// Score is based on int and implements String()
type Score int
func (s Score) String() string {
return fmt.Sprintf("Score: %d", s)
}
// Temperature is based on float64 and implements String()
type Temperature float64
func (t Temperature) String() string {
return fmt.Sprintf("%.1f°C", t)
}
// Status is based on string and implements String()
type Status string
func (s Status) String() string {
return fmt.Sprintf("Status: %s", string(s))
}
// section: types
// section: function
// Display is a generic function that accepts any Printable type
func Display[T Printable](value T) {
// We can call String() because the Printable constraint requires it
fmt.Println(value.String())
}
// Compare compares two Printable values
// Note: This works because all types in Printable constraint support comparison
func Compare[T Printable](a, b T) string {
// We can use comparison operators because underlying types are comparable
if a == b {
return fmt.Sprintf("%s equals %s", a.String(), b.String())
}
return fmt.Sprintf("%s does not equal %s", a.String(), b.String())
}
// section: function
// section: usage
func main() {
// Create instances of our custom types
score1 := Score(95)
score2 := Score(87)
temp := Temperature(23.5)
status := Status("active")
// Display using the generic Display function
fmt.Println("=== Display Examples ===")
Display(score1)
Display(temp)
Display(status)
// Compare values
fmt.Println("\n=== Comparison Examples ===")
fmt.Println(Compare(score1, score2))
fmt.Println(Compare(score1, Score(95)))
// We can also use the values in generic collections
fmt.Println("\n=== Collection Example ===")
scores := []Score{Score(85), Score(92), Score(78)}
fmt.Println("All scores:")
for _, s := range scores {
Display(s)
}
}
// section: usageBenefits Of Mixed Constraints
This pattern provides several important advantages:
- Type Safety: The compiler ensures types meet both the type and method requirements
- Controlled Flexibility: You can accept multiple types while ensuring consistent behavior
- Method Guarantees: You know certain methods are available without type assertions
- Domain Modeling: Perfect for domain-specific types that share operations but need distinct types
- Compile-Time Checking: All constraint violations are caught at compile time, not runtime
Common Use Cases:
- Numeric types with custom formatting (scores, temperatures, currencies)
- Identifiable entities with specific ID types (User, Product, Order)
- Ordered types with custom string representations
- Domain-specific types that need both arithmetic and display operations
Type Instantiation
Type instantiation is when we explicitly specify the type parameters for a generic function or type. While Go’s compiler is quite good at inferring types from context, there are situations where we need to be explicit.
Basic Concept
Sometimes you need to tell the compiler exactly which types you want:
// Max returns the maximum of two values
func Max[T constraints.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
func main() {
// Type inference works here - Go knows we want Max[int]
result1 := Max(5, 8)
fmt.Printf("Max(5, 8) = %d\n", result1)
// Type instantiation - we explicitly specify the type
result2 := Max[int](5, 8)
fmt.Printf("Max[int](5, 8) = %d\n", result2)
// Both calls do the same thing, but the second is explicit
}
package main
import (
"fmt"
"golang.org/x/exp/constraints"
)
// section: basic
// Max returns the maximum of two values
func Max[T constraints.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
func main() {
// Type inference works here - Go knows we want Max[int]
result1 := Max(5, 8)
fmt.Printf("Max(5, 8) = %d\n", result1)
// Type instantiation - we explicitly specify the type
result2 := Max[int](5, 8)
fmt.Printf("Max[int](5, 8) = %d\n", result2)
// Both calls do the same thing, but the second is explicit
}
// section: basicWhen Type Instantiation Is Required
Function Variables
When assigning a generic function to a variable, explicit instantiation is required:
// Max returns the maximum of two values
func Max[T constraints.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
func main() {
// When assigning a generic function to a variable,
// we need explicit type instantiation
// This would NOT compile:
// var maxFunc = Max // compiler error: cannot infer types
// This works - explicit instantiation:
var maxInt func(int, int) int = Max[int]
var maxString func(string, string) string = Max[string]
// Now we can use the function variables
fmt.Printf("Max integers: %d\n", maxInt(5, 8))
fmt.Printf("Max strings: %s\n", maxString("apple", "banana"))
// We can also use shorter syntax:
maxFloat := Max[float64]
fmt.Printf("Max floats: %.2f\n", maxFloat(3.14, 2.71))
}
package main
import (
"fmt"
"golang.org/x/exp/constraints"
)
// section: function-vars
// Max returns the maximum of two values
func Max[T constraints.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
func main() {
// When assigning a generic function to a variable,
// we need explicit type instantiation
// This would NOT compile:
// var maxFunc = Max // compiler error: cannot infer types
// This works - explicit instantiation:
var maxInt func(int, int) int = Max[int]
var maxString func(string, string) string = Max[string]
// Now we can use the function variables
fmt.Printf("Max integers: %d\n", maxInt(5, 8))
fmt.Printf("Max strings: %s\n", maxString("apple", "banana"))
// We can also use shorter syntax:
maxFloat := Max[float64]
fmt.Printf("Max floats: %.2f\n", maxFloat(3.14, 2.71))
}
// section: function-varsMultiple Type Parameters
When a function has multiple type parameters, the compiler often can’t infer all of them:
// Pair represents a pair of values
type Pair[T, U any] struct {
First T
Second U
}
// NewPair creates a new pair
func NewPair[T, U any](first T, second U) Pair[T, U] {
return Pair[T, U]{First: first, Second: second}
}
// Transform applies a function to convert first to second type
func Transform[T, U any](value T, converter func(T) U) U {
return converter(value)
}
func main() {
// When a function has multiple type parameters,
// the compiler often can't infer all of them
// This works - compiler can infer both T and U from arguments:
pair1 := NewPair("hello", 42)
fmt.Printf("Inferred pair: %+v\n", pair1)
// But sometimes we need to be explicit:
pair2 := NewPair[string, int]("world", 100)
fmt.Printf("Explicit pair: %+v\n", pair2)
// Transform function needs explicit instantiation
// because the return type U can't be inferred from the first argument
result := Transform[int, string](42, func(i int) string {
return fmt.Sprintf("Number: %d", i)
})
fmt.Printf("Transformed: %s\n", result)
}
package main
import "fmt"
// section: multiple-params
// Pair represents a pair of values
type Pair[T, U any] struct {
First T
Second U
}
// NewPair creates a new pair
func NewPair[T, U any](first T, second U) Pair[T, U] {
return Pair[T, U]{First: first, Second: second}
}
// Transform applies a function to convert first to second type
func Transform[T, U any](value T, converter func(T) U) U {
return converter(value)
}
func main() {
// When a function has multiple type parameters,
// the compiler often can't infer all of them
// This works - compiler can infer both T and U from arguments:
pair1 := NewPair("hello", 42)
fmt.Printf("Inferred pair: %+v\n", pair1)
// But sometimes we need to be explicit:
pair2 := NewPair[string, int]("world", 100)
fmt.Printf("Explicit pair: %+v\n", pair2)
// Transform function needs explicit instantiation
// because the return type U can't be inferred from the first argument
result := Transform[int, string](42, func(i int) string {
return fmt.Sprintf("Number: %d", i)
})
fmt.Printf("Transformed: %s\n", result)
}
// section: multiple-paramsAmbiguous Context
Sometimes the context doesn’t provide enough information for type inference:
// Convert converts a numeric value to a string
func Convert[T constraints.Ordered](value T) string {
return fmt.Sprintf("%v", value)
}
// Zero returns the zero value for any type
func Zero[T any]() T {
var zero T
return zero
}
func main() {
// Sometimes the context doesn't provide enough information
// for the compiler to infer the type
// This works - type is inferred from the argument:
result1 := Convert(42)
fmt.Printf("Convert(42): %s\n", result1)
// But when creating a function variable, we need explicit types:
converter := Convert[int] // Must specify T = int
result2 := converter(123)
fmt.Printf("converter(123): %s\n", result2)
// Zero function always needs explicit instantiation
// because there's no argument to infer from:
zeroInt := Zero[int]()
zeroString := Zero[string]()
fmt.Printf("Zero int: %d\n", zeroInt)
fmt.Printf("Zero string: '%s'\n", zeroString)
// This would NOT compile:
// unknown := Zero() // compiler error: cannot infer T
}
package main
import (
"fmt"
"golang.org/x/exp/constraints"
)
// section: ambiguous
// Convert converts a numeric value to a string
func Convert[T constraints.Ordered](value T) string {
return fmt.Sprintf("%v", value)
}
// Zero returns the zero value for any type
func Zero[T any]() T {
var zero T
return zero
}
func main() {
// Sometimes the context doesn't provide enough information
// for the compiler to infer the type
// This works - type is inferred from the argument:
result1 := Convert(42)
fmt.Printf("Convert(42): %s\n", result1)
// But when creating a function variable, we need explicit types:
converter := Convert[int] // Must specify T = int
result2 := converter(123)
fmt.Printf("converter(123): %s\n", result2)
// Zero function always needs explicit instantiation
// because there's no argument to infer from:
zeroInt := Zero[int]()
zeroString := Zero[string]()
fmt.Printf("Zero int: %d\n", zeroInt)
fmt.Printf("Zero string: '%s'\n", zeroString)
// This would NOT compile:
// unknown := Zero() // compiler error: cannot infer T
}
// section: ambiguousZero Values Of Generic Types
When creating zero values of generic types, explicit instantiation is required:
// Stack is a generic stack data structure
type Stack[T any] struct {
items []T
}
// NewStack creates a new stack
func NewStack[T any]() *Stack[T] {
return &Stack[T]{items: make([]T, 0)}
}
// Push adds an item to the stack
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
// Pop removes and returns the top item
func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T
return zero, false
}
idx := len(s.items) - 1
item := s.items[idx]
clear(s.items[idx:])
s.items = s.items[:idx]
return item, true
}
func main() {
// When creating zero values of generic types,
// we need explicit type instantiation
// Creating zero values requires explicit types:
var intStack Stack[int] // Zero value of Stack[int]
var stringStack Stack[string] // Zero value of Stack[string]
fmt.Printf("Zero int stack: %+v\n", intStack)
fmt.Printf("Zero string stack: %+v\n", stringStack)
// Using constructor functions:
numberStack := NewStack[int]()
wordStack := NewStack[string]()
// Use the stacks:
numberStack.Push(42)
wordStack.Push("hello")
num, ok := numberStack.Pop()
fmt.Printf("Popped number: %d (ok: %t)\n", num, ok)
word, ok := wordStack.Pop()
fmt.Printf("Popped word: %s (ok: %t)\n", word, ok)
}
package main
import "fmt"
// section: zero-values
// Stack is a generic stack data structure
type Stack[T any] struct {
items []T
}
// NewStack creates a new stack
func NewStack[T any]() *Stack[T] {
return &Stack[T]{items: make([]T, 0)}
}
// Push adds an item to the stack
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
// Pop removes and returns the top item
func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T
return zero, false
}
idx := len(s.items) - 1
item := s.items[idx]
clear(s.items[idx:])
s.items = s.items[:idx]
return item, true
}
func main() {
// When creating zero values of generic types,
// we need explicit type instantiation
// Creating zero values requires explicit types:
var intStack Stack[int] // Zero value of Stack[int]
var stringStack Stack[string] // Zero value of Stack[string]
fmt.Printf("Zero int stack: %+v\n", intStack)
fmt.Printf("Zero string stack: %+v\n", stringStack)
// Using constructor functions:
numberStack := NewStack[int]()
wordStack := NewStack[string]()
// Use the stacks:
numberStack.Push(42)
wordStack.Push("hello")
num, ok := numberStack.Pop()
fmt.Printf("Popped number: %d (ok: %t)\n", num, ok)
word, ok := wordStack.Pop()
fmt.Printf("Popped word: %s (ok: %t)\n", word, ok)
}
// section: zero-valuesBest Practices And Common Pitfalls
Generics are a powerful tool, but like any feature, they can be misused. Let’s explore when to use them, when to avoid them, and common pitfalls to watch out for.
When To Use Generics
✅ Good Use Cases:
Data Structures: Generic containers like stacks, queues, trees, graphs
type Stack[T any] struct { items []T }- Algorithms: Functions that work with multiple types using the same logic
go func Map[T, U any](slice []T, fn func(T) U) []U func Filter[T any](slice []T, predicate func(T) bool) []T
- Algorithms: Functions that work with multiple types using the same logic
Type-Safe Wrappers: Generic result types, option types, or error handling
type Result[T any] struct { Value T; Err error }- Multiple Type Operations: Functions that need to maintain relationships between types
go func Keys[K comparable, V any](m map[K]V) []K
- Multiple Type Operations: Functions that need to maintain relationships between types
When NOT To Use Generics
❌ Avoid Generics When:
- Simple Interface Suffices: If
io.Reader,fmt.Stringer, or other interfaces work, use them “`go // ❌ Overcomplicated func PrintT any { fmt.Println(v) }
// ✅ Better - just use any or interface func Print(v any) { fmt.Println(v) }
2. **Method-Based Behavior**: When you need behavior based on methods, not types
```go
// ❌ Wrong approach - generics don't help here
func Process[T any](items []T) { /* needs methods on T */ }
// ✅ Better - use interface
type Processor interface { Process() }
func Process(items []Processor) { /* can call methods */ }
- Single Type: If your function only ever works with one type, don’t make it generic “`go // ❌ Unnecessary complexity func AddIntsT int T { return a + b }
// ✅ Just use the type directly func AddInts(a, b int) int { return a + b } “`
- Performance Critical Code: Generic code can be slightly slower due to dictionary passing
- For tight loops or hot paths, concrete types may be faster
- Profile before optimizing - the difference is usually negligible
Common Pitfalls
1. Over-Constraining
// ❌ Too specific - limits flexibility
func Process[T constraints.Integer](values []T) []T
// ✅ Only constrain what you actually need
func Process[T any](values []T) []T // If you don't need arithmetic
2. Forgetting Type Instantiation
// ❌ This won't compile
var fn = Filter // Can't infer types
// ✅ Explicitly instantiate
var fn = Filter[int]
3. Confusing any with Empty Interface Behavior
// Generic 'any' is NOT the same as interface{}
func Print[T any](v T) {
// ❌ Can't call methods on T without constraints
// v.String() // Won't compile
}
4. Mixing Generic and Non-Generic Code
// ❌ Can't have generic methods (only functions and types)
func (s *Service) Process[T any](data T) {} // Won't compile
// ✅ Make the type generic instead
type Service[T any] struct {}
func (s *Service[T]) Process(data T) {}
Note: Go 1.27 (expected August 2026) is planned to allow type parameters on methods. Until that ships, making the whole type generic, as shown here, remains the pattern to use.
5. Unnecessary Type Switching
// ❌ Defeats the purpose of generics
func Process[T any](v T) {
switch x := any(v).(type) {
case int: // ...
case string: // ...
}
}
// ✅ If you need different behavior, use constraints or interfaces
Debugging Tips
Understanding Compiler Errors:
Generic error messages can be verbose. Common patterns:
does not satisfy constraint X
→ Your type doesn’t meet the constraint requirements. Check if:
- Type is in the union (
int | string | float64) - Type implements required methods
- Type satisfies built-in constraints (comparable, ordered)
cannot infer T
→ Compiler can’t determine the type. Explicitly instantiate:
result := MyFunc[int](value) // Specify type explicitly
Testing Generic Code:
- Test with multiple types from your constraint
- Test with edge cases (empty values, zero values)
- Test type inference works as expected
- Consider table-driven tests with different types
func TestFilter(t *testing.T) {
// Test with int
intResult := Filter([]int{1,2,3}, func(i int) bool { return i > 1 })
// Test with string
strResult := Filter([]string{"a","b"}, func(s string) bool { return len(s) > 0 })
}
Performance Considerations
What You Should Know:
GCShape Stenciling: Go groups similar types together
- All pointer types share one implementation
- Each primitive type gets its own implementation
- Slight overhead from dictionary passing
When It Matters:
- Very tight loops (millions of iterations)
- Hot paths in performance-critical applications
- Embedded systems with strict performance requirements
When It Doesn’t Matter:
- Most application code
- I/O-bound operations
- User-facing features
Rule of Thumb: Write generic code for clarity and type safety. Only optimize to concrete types if profiling shows it’s a bottleneck.
Quick Reference: Best Practices
- ✅ Start simple: Use
anywhen you don’t need specific constraints - ✅ Be specific: Use constraints to limit types when appropriate
- ✅ Use predefined constraints: Leverage
golang.org/x/exp/constraintswhen possible - ✅ Prefer constraints over type switching: Let the type system do the work
- ✅ Test with multiple types: Ensure your generic code works across all intended types
- ✅ Consider readability: Generic code should still be easy to understand
- ✅ Profile before optimizing: Don’t avoid generics for performance without evidence
- ❌ Don’t over-generalize: If simple interfaces work, use them
- ❌ Don’t use for everything: Generics are a tool, not a requirement
- ❌ Don’t sacrifice clarity: If generics make code harder to understand, reconsider
Exercise (25 Minues)
Objective: Implement a generic Queue (FIFO - First In, First Out) data structure.
Requirements:
- Generic type:
Queue[T any] - Constructor:
NewQueue[T any]() *Queue[T] - Methods:
Enqueue(item T)- add item to backDequeue() (T, bool)- remove item from frontPeek() (T, bool)- view front item without removingSize() int- return number of itemsIsEmpty() bool- check if queue is empty
Key Difference from Stack: Queue removes from front (FIFO), Stack removes from back (LIFO)
package main
import "fmt"
// TODO: Implement a generic Queue data structure
//
// Queue should be a generic type that can work with any type T
//
// TODO: Implement a constructor function
//
// TODO: Implement the following methods:
//
// TODO: Implement the following methods:
// Enqueue adds an item to the back of the queue
//
// Dequeue removes and returns the item from the front of the queue
// Returns the item and a boolean indicating success
//
// Peek returns the front item without removing it
// Returns the item and a boolean indicating success
//
// Size returns the number of items in the queue
//
// IsEmpty returns true if the queue is empty
func main() {
// Test with integers
fmt.Println("=== Testing Integer Queue ===")
intQueue := NewQueue[int]()
// Add some numbers
intQueue.Enqueue(10)
intQueue.Enqueue(20)
intQueue.Enqueue(30)
fmt.Printf("Queue size: %d\n", intQueue.Size())
fmt.Printf("Is empty: %t\n", intQueue.IsEmpty())
// Peek at front
if front, ok := intQueue.Peek(); ok {
fmt.Printf("Front item: %d\n", front)
}
// Dequeue all items
for !intQueue.IsEmpty() {
if item, ok := intQueue.Dequeue(); ok {
fmt.Printf("Dequeued: %d\n", item)
}
}
fmt.Printf("After dequeuing all items - Size: %d, IsEmpty: %t\n", intQueue.Size(), intQueue.IsEmpty())
// Test with strings
fmt.Println("\n=== Testing String Queue ===")
stringQueue := NewQueue[string]()
// Add some strings
stringQueue.Enqueue("first")
stringQueue.Enqueue("second")
stringQueue.Enqueue("third")
fmt.Printf("Queue size: %d\n", stringQueue.Size())
// Dequeue and print
for !stringQueue.IsEmpty() {
if item, ok := stringQueue.Dequeue(); ok {
fmt.Printf("Dequeued: %s\n", item)
}
}
// Test dequeue from empty queue
fmt.Println("\n=== Testing Empty Queue ===")
emptyQueue := NewQueue[int]()
if _, ok := emptyQueue.Dequeue(); !ok {
fmt.Println("Cannot dequeue from empty queue - handled correctly!")
}
if _, ok := emptyQueue.Peek(); !ok {
fmt.Println("Cannot peek empty queue - handled correctly!")
}
}package main
import "fmt"
// TODO: Implement a generic Queue data structure
//
// Queue should be a generic type that can work with any type T
//
// TODO: Implement a constructor function
//
// TODO: Implement the following methods:
//
// TODO: Implement the following methods:
// Enqueue adds an item to the back of the queue
//
// Dequeue removes and returns the item from the front of the queue
// Returns the item and a boolean indicating success
//
// Peek returns the front item without removing it
// Returns the item and a boolean indicating success
//
// Size returns the number of items in the queue
//
// IsEmpty returns true if the queue is empty
func main() {
// Test with integers
fmt.Println("=== Testing Integer Queue ===")
intQueue := NewQueue[int]()
// Add some numbers
intQueue.Enqueue(10)
intQueue.Enqueue(20)
intQueue.Enqueue(30)
fmt.Printf("Queue size: %d\n", intQueue.Size())
fmt.Printf("Is empty: %t\n", intQueue.IsEmpty())
// Peek at front
if front, ok := intQueue.Peek(); ok {
fmt.Printf("Front item: %d\n", front)
}
// Dequeue all items
for !intQueue.IsEmpty() {
if item, ok := intQueue.Dequeue(); ok {
fmt.Printf("Dequeued: %d\n", item)
}
}
fmt.Printf("After dequeuing all items - Size: %d, IsEmpty: %t\n", intQueue.Size(), intQueue.IsEmpty())
// Test with strings
fmt.Println("\n=== Testing String Queue ===")
stringQueue := NewQueue[string]()
// Add some strings
stringQueue.Enqueue("first")
stringQueue.Enqueue("second")
stringQueue.Enqueue("third")
fmt.Printf("Queue size: %d\n", stringQueue.Size())
// Dequeue and print
for !stringQueue.IsEmpty() {
if item, ok := stringQueue.Dequeue(); ok {
fmt.Printf("Dequeued: %s\n", item)
}
}
// Test dequeue from empty queue
fmt.Println("\n=== Testing Empty Queue ===")
emptyQueue := NewQueue[int]()
if _, ok := emptyQueue.Dequeue(); !ok {
fmt.Println("Cannot dequeue from empty queue - handled correctly!")
}
if _, ok := emptyQueue.Peek(); !ok {
fmt.Println("Cannot peek empty queue - handled correctly!")
}
}Exercise Solution
The solution demonstrates advanced generic data structure concepts:
- Generic Type Definition:
Queue[T any]with type parameter - Constructor Pattern:
NewQueue[T any]()for type-safe initialization - Method Implementation: All methods work with the generic type
T - Zero Value Handling: Proper handling of empty queue operations
- Type Safety: Each queue instance is tied to a specific type
package main
import "fmt"
// Queue is a generic FIFO (First In, First Out) data structure
type Queue[T any] struct {
items []T
}
// NewQueue creates a new empty queue
func NewQueue[T any]() *Queue[T] {
return &Queue[T]{
items: make([]T, 0),
}
}
// Enqueue adds an item to the back of the queue
func (q *Queue[T]) Enqueue(item T) {
q.items = append(q.items, item)
}
// Dequeue removes and returns the item from the front of the queue
// Returns the item and a boolean indicating success
func (q *Queue[T]) Dequeue() (T, bool) {
var zero T
if len(q.items) == 0 {
return zero, false
}
item := q.items[0]
clear(q.items[:1])
q.items = q.items[1:] // Remove the first element
return item, true
}
// Peek returns the front item without removing it
// Returns the item and a boolean indicating success
func (q *Queue[T]) Peek() (T, bool) {
var zero T
if len(q.items) == 0 {
return zero, false
}
return q.items[0], true
}
// Size returns the number of items in the queue
func (q *Queue[T]) Size() int {
return len(q.items)
}
// IsEmpty returns true if the queue is empty
func (q *Queue[T]) IsEmpty() bool {
return len(q.items) == 0
}
func main() {
// Test with integers
fmt.Println("=== Testing Integer Queue ===")
intQueue := NewQueue[int]()
// Add some numbers
intQueue.Enqueue(10)
intQueue.Enqueue(20)
intQueue.Enqueue(30)
fmt.Printf("Queue size: %d\n", intQueue.Size())
fmt.Printf("Is empty: %t\n", intQueue.IsEmpty())
// Peek at front
if front, ok := intQueue.Peek(); ok {
fmt.Printf("Front item: %d\n", front)
}
// Dequeue all items
for !intQueue.IsEmpty() {
if item, ok := intQueue.Dequeue(); ok {
fmt.Printf("Dequeued: %d\n", item)
}
}
fmt.Printf("After dequeuing all items - Size: %d, IsEmpty: %t\n", intQueue.Size(), intQueue.IsEmpty())
// Test with strings
fmt.Println("\n=== Testing String Queue ===")
stringQueue := NewQueue[string]()
// Add some strings
stringQueue.Enqueue("first")
stringQueue.Enqueue("second")
stringQueue.Enqueue("third")
fmt.Printf("Queue size: %d\n", stringQueue.Size())
// Dequeue and print
for !stringQueue.IsEmpty() {
if item, ok := stringQueue.Dequeue(); ok {
fmt.Printf("Dequeued: %s\n", item)
}
}
// Test dequeue from empty queue
fmt.Println("\n=== Testing Empty Queue ===")
emptyQueue := NewQueue[int]()
if _, ok := emptyQueue.Dequeue(); !ok {
fmt.Println("Cannot dequeue from empty queue - handled correctly!")
}
if _, ok := emptyQueue.Peek(); !ok {
fmt.Println("Cannot peek empty queue - handled correctly!")
}
}package main
import "fmt"
// Queue is a generic FIFO (First In, First Out) data structure
type Queue[T any] struct {
items []T
}
// NewQueue creates a new empty queue
func NewQueue[T any]() *Queue[T] {
return &Queue[T]{
items: make([]T, 0),
}
}
// Enqueue adds an item to the back of the queue
func (q *Queue[T]) Enqueue(item T) {
q.items = append(q.items, item)
}
// Dequeue removes and returns the item from the front of the queue
// Returns the item and a boolean indicating success
func (q *Queue[T]) Dequeue() (T, bool) {
var zero T
if len(q.items) == 0 {
return zero, false
}
item := q.items[0]
clear(q.items[:1])
q.items = q.items[1:] // Remove the first element
return item, true
}
// Peek returns the front item without removing it
// Returns the item and a boolean indicating success
func (q *Queue[T]) Peek() (T, bool) {
var zero T
if len(q.items) == 0 {
return zero, false
}
return q.items[0], true
}
// Size returns the number of items in the queue
func (q *Queue[T]) Size() int {
return len(q.items)
}
// IsEmpty returns true if the queue is empty
func (q *Queue[T]) IsEmpty() bool {
return len(q.items) == 0
}
func main() {
// Test with integers
fmt.Println("=== Testing Integer Queue ===")
intQueue := NewQueue[int]()
// Add some numbers
intQueue.Enqueue(10)
intQueue.Enqueue(20)
intQueue.Enqueue(30)
fmt.Printf("Queue size: %d\n", intQueue.Size())
fmt.Printf("Is empty: %t\n", intQueue.IsEmpty())
// Peek at front
if front, ok := intQueue.Peek(); ok {
fmt.Printf("Front item: %d\n", front)
}
// Dequeue all items
for !intQueue.IsEmpty() {
if item, ok := intQueue.Dequeue(); ok {
fmt.Printf("Dequeued: %d\n", item)
}
}
fmt.Printf("After dequeuing all items - Size: %d, IsEmpty: %t\n", intQueue.Size(), intQueue.IsEmpty())
// Test with strings
fmt.Println("\n=== Testing String Queue ===")
stringQueue := NewQueue[string]()
// Add some strings
stringQueue.Enqueue("first")
stringQueue.Enqueue("second")
stringQueue.Enqueue("third")
fmt.Printf("Queue size: %d\n", stringQueue.Size())
// Dequeue and print
for !stringQueue.IsEmpty() {
if item, ok := stringQueue.Dequeue(); ok {
fmt.Printf("Dequeued: %s\n", item)
}
}
// Test dequeue from empty queue
fmt.Println("\n=== Testing Empty Queue ===")
emptyQueue := NewQueue[int]()
if _, ok := emptyQueue.Dequeue(); !ok {
fmt.Println("Cannot dequeue from empty queue - handled correctly!")
}
if _, ok := emptyQueue.Peek(); !ok {
fmt.Println("Cannot peek empty queue - handled correctly!")
}
}Key Learning Points:
- How to create generic data structures
- Implementing methods on generic types
- Handling zero values with
var zero T - Constructor patterns for generic types
- Type safety across multiple queue instances
Exercise (25 Minutes)
Objective: Implement generic Map and Reduce functions - two fundamental operations in functional programming.
Requirements:
Map Function:
- Signature:
Map[T any, U any](slice []T, fn func(T) U) []U - Transforms each element in a slice using the provided function
- Returns a new slice with transformed values
- Input type
Tand output typeUcan be different
Reduce Function:
- Signature:
Reduce[T any, U any](slice []T, initial U, fn func(U, T) U) U - Combines all elements in a slice into a single value
- Takes an initial accumulator value
- Applies the function to accumulator and each element sequentially
- Returns the final accumulated value
Understanding Map and Reduce:
Map: Transforms each element independently (like applying a function to every item)
- Example: Convert numbers to strings, square all numbers, get lengths of strings
Reduce: Combines elements into a single result (like folding a list)
- Example: Sum, product, concatenation, finding min/max
Test Cases to Implement:
- Map integers to strings with formatting
- Map integers to squares
- Map strings to their lengths
- Reduce integers to sum
- Reduce integers to product
- Reduce strings to concatenated sentence
- Reduce integers to find maximum
package main
import "fmt"
// TODO: Implement the Map function
// Map transforms each element in a slice using the provided function
// func Map[T any, U any](slice []T, fn func(T) U) []U
// TODO: Implement the Reduce function
// Reduce combines all elements in a slice into a single value
// func Reduce[T any, U any](slice []T, initial U, fn func(U, T) U) U
func main() {
// Test Map: Convert integers to strings
numbers := []int{1, 2, 3, 4, 5}
strings := Map(numbers, func(n int) string {
return fmt.Sprintf("Number: %d", n)
})
fmt.Println("Mapped to strings:", strings)
// Test Map: Square numbers
squared := Map(numbers, func(n int) int {
return n * n
})
fmt.Println("Squared numbers:", squared)
// Test Map: Convert strings to their lengths
words := []string{"go", "rust", "python", "javascript"}
lengths := Map(words, func(s string) int {
return len(s)
})
fmt.Println("Word lengths:", lengths)
// Test Reduce: Sum of integers
sum := Reduce(numbers, 0, func(acc int, n int) int {
return acc + n
})
fmt.Println("Sum:", sum)
// Test Reduce: Product of integers
product := Reduce(numbers, 1, func(acc int, n int) int {
return acc * n
})
fmt.Println("Product:", product)
// Test Reduce: Concatenate strings
sentence := Reduce(words, "", func(acc string, word string) string {
if acc == "" {
return word
}
return acc + " " + word
})
fmt.Println("Concatenated:", sentence)
// Test Reduce: Find maximum value
maxValues := []int{3, 7, 2, 9, 1, 5}
max := Reduce(maxValues, maxValues[0], func(acc int, n int) int {
if n > acc {
return n
}
return acc
})
fmt.Println("Maximum:", max)
}package main
import "fmt"
// TODO: Implement the Map function
// Map transforms each element in a slice using the provided function
// func Map[T any, U any](slice []T, fn func(T) U) []U
// TODO: Implement the Reduce function
// Reduce combines all elements in a slice into a single value
// func Reduce[T any, U any](slice []T, initial U, fn func(U, T) U) U
func main() {
// Test Map: Convert integers to strings
numbers := []int{1, 2, 3, 4, 5}
strings := Map(numbers, func(n int) string {
return fmt.Sprintf("Number: %d", n)
})
fmt.Println("Mapped to strings:", strings)
// Test Map: Square numbers
squared := Map(numbers, func(n int) int {
return n * n
})
fmt.Println("Squared numbers:", squared)
// Test Map: Convert strings to their lengths
words := []string{"go", "rust", "python", "javascript"}
lengths := Map(words, func(s string) int {
return len(s)
})
fmt.Println("Word lengths:", lengths)
// Test Reduce: Sum of integers
sum := Reduce(numbers, 0, func(acc int, n int) int {
return acc + n
})
fmt.Println("Sum:", sum)
// Test Reduce: Product of integers
product := Reduce(numbers, 1, func(acc int, n int) int {
return acc * n
})
fmt.Println("Product:", product)
// Test Reduce: Concatenate strings
sentence := Reduce(words, "", func(acc string, word string) string {
if acc == "" {
return word
}
return acc + " " + word
})
fmt.Println("Concatenated:", sentence)
// Test Reduce: Find maximum value
maxValues := []int{3, 7, 2, 9, 1, 5}
max := Reduce(maxValues, maxValues[0], func(acc int, n int) int {
if n > acc {
return n
}
return acc
})
fmt.Println("Maximum:", max)
}Exercise Solution
This solution demonstrates advanced generic programming concepts:
- Multiple Type Parameters: Both
MapandReduceuse two type parameters (TandU) - Type Transformation:
Mapshows how to transform from typeTto typeU - Accumulator Pattern:
Reducedemonstrates the fold/accumulator pattern - Higher-Order Functions: Both functions accept functions as parameters
- Flexibility: The same functions work with any types and any transformation logic
Map Implementation:
func Map[T any, U any](slice []T, fn func(T) U) []U {
result := make([]U, len(slice)) // Pre-allocate result slice
for i, v := range slice {
result[i] = fn(v) // Transform each element
}
return result
}
Reduce Implementation:
func Reduce[T any, U any](slice []T, initial U, fn func(U, T) U) U {
acc := initial // Start with initial value
for _, v := range slice {
acc = fn(acc, v) // Accumulate each element
}
return acc
}
package main
import "fmt"
// Map transforms each element in a slice using the provided function
func Map[T any, U any](slice []T, fn func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = fn(v)
}
return result
}
// Reduce combines all elements in a slice into a single value
func Reduce[T any, U any](slice []T, initial U, fn func(U, T) U) U {
acc := initial
for _, v := range slice {
acc = fn(acc, v)
}
return acc
}
func main() {
// Test Map: Convert integers to strings
numbers := []int{1, 2, 3, 4, 5}
strings := Map(numbers, func(n int) string {
return fmt.Sprintf("Number: %d", n)
})
fmt.Println("Mapped to strings:", strings)
// Test Map: Square numbers
squared := Map(numbers, func(n int) int {
return n * n
})
fmt.Println("Squared numbers:", squared)
// Test Map: Convert strings to their lengths
words := []string{"go", "rust", "python", "javascript"}
lengths := Map(words, func(s string) int {
return len(s)
})
fmt.Println("Word lengths:", lengths)
// Test Reduce: Sum of integers
sum := Reduce(numbers, 0, func(acc int, n int) int {
return acc + n
})
fmt.Println("Sum:", sum)
// Test Reduce: Product of integers
product := Reduce(numbers, 1, func(acc int, n int) int {
return acc * n
})
fmt.Println("Product:", product)
// Test Reduce: Concatenate strings
sentence := Reduce(words, "", func(acc string, word string) string {
if acc == "" {
return word
}
return acc + " " + word
})
fmt.Println("Concatenated:", sentence)
// Test Reduce: Find maximum value
maxValues := []int{3, 7, 2, 9, 1, 5}
max := Reduce(maxValues, maxValues[0], func(acc int, n int) int {
if n > acc {
return n
}
return acc
})
fmt.Println("Maximum:", max)
}package main
import "fmt"
// Map transforms each element in a slice using the provided function
func Map[T any, U any](slice []T, fn func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = fn(v)
}
return result
}
// Reduce combines all elements in a slice into a single value
func Reduce[T any, U any](slice []T, initial U, fn func(U, T) U) U {
acc := initial
for _, v := range slice {
acc = fn(acc, v)
}
return acc
}
func main() {
// Test Map: Convert integers to strings
numbers := []int{1, 2, 3, 4, 5}
strings := Map(numbers, func(n int) string {
return fmt.Sprintf("Number: %d", n)
})
fmt.Println("Mapped to strings:", strings)
// Test Map: Square numbers
squared := Map(numbers, func(n int) int {
return n * n
})
fmt.Println("Squared numbers:", squared)
// Test Map: Convert strings to their lengths
words := []string{"go", "rust", "python", "javascript"}
lengths := Map(words, func(s string) int {
return len(s)
})
fmt.Println("Word lengths:", lengths)
// Test Reduce: Sum of integers
sum := Reduce(numbers, 0, func(acc int, n int) int {
return acc + n
})
fmt.Println("Sum:", sum)
// Test Reduce: Product of integers
product := Reduce(numbers, 1, func(acc int, n int) int {
return acc * n
})
fmt.Println("Product:", product)
// Test Reduce: Concatenate strings
sentence := Reduce(words, "", func(acc string, word string) string {
if acc == "" {
return word
}
return acc + " " + word
})
fmt.Println("Concatenated:", sentence)
// Test Reduce: Find maximum value
maxValues := []int{3, 7, 2, 9, 1, 5}
max := Reduce(maxValues, maxValues[0], func(acc int, n int) int {
if n > acc {
return n
}
return acc
})
fmt.Println("Maximum:", max)
}Key Learning Points:
- How to use two different type parameters in one function
- Transforming between different types (
Map[int, string]) - Accumulator pattern for combining values (
Reduce) - Higher-order functions with generics
- Pre-allocating slices for better performance (
make([]U, len(slice))) - The power of functional programming patterns with type safety
Real-World Applications:
- Map: Data transformation pipelines, DTO conversions, formatting collections
- Reduce: Aggregations, statistics, combining configuration, building complex objects from lists
What You've Learned
Through all three exercises in this module, you’ve practiced:
- Generic Functions: Creating reusable functions that work with any type
- Generic Data Structures: Building type-safe containers
- Type Parameters: Using
[T any]to make code flexible - Type Safety: Maintaining compile-time type checking
- Zero Values: Properly handling empty states in generic code
- Constructor Patterns: Creating instances of generic types
- Method Implementation: Adding behavior to generic types
These concepts form the foundation for writing flexible, reusable, and type-safe Go code using generics.