Generics Explained: Reusable, Type-Safe Code 🧬

When writing software in statically typed languages, you frequently need to write logic that works across multiple different data types—such as a list, a cache, a stack, or a sorting algorithm.

Before Generics, developers were stuck with a frustrating dilemma: duplicate code for every type, or lose type safety by using untyped placeholders (like Object in Java or any / interface{} in Go).


1. The Problem Generics Solve

Imagine you want a helper function that reverses a slice/array. Without generics in a typed language:

// Option A: Write duplicate functions for every single type! 😫
func ReverseInts(s []int) []int { /* ... */ }
func ReverseStrings(s []string) []string { /* ... */ }
func ReverseUsers(s []User) []User { /* ... */ }
 
// Option B: Use 'any', but lose all type safety and require ugly casting! ⚠️
func ReverseAny(s []any) []any { /* ... */ }

With Generics, you write the function once and use a Type Parameter (T) as a placeholder that gets filled in with the concrete type when called:

// ✅ With Generics: Written once, works for ANY type, completely type-safe!
func Reverse[T any](s []T) []T {
	result := make([]T, len(s))
	for i, v := range s {
		result[len(s)-1-i] = v
	}
	return result
}
 
func main() {
	numbers := Reverse([]int{1, 2, 3})         // Compiler knows result is []int!
	names   := Reverse([]string{"a", "b", "c"}) // Compiler knows result is []string!
}

2. Common Real-World Use Cases for Generics

1. Generic Data Structures (Collections)

Building reusable containers like stacks, queues, linked lists, or LRU caches:

// Generic Stack in TypeScript
class Stack<T> {
  private items: T[] = [];
 
  push(item: T): void {
    this.items.push(item);
  }
 
  pop(): T | undefined {
    return this.items.pop();
  }
}
 
const stringStack = new Stack<string>();
stringStack.push("hello");
// stringStack.push(42); ❌ Compiler error: number is not string!

2. Generic HTTP API Responses

In microservices, API responses often share the same metadata wrapper (status, timestamp, error) around different data payloads:

interface ApiResponse<T> {
  status: "success" | "error";
  data: T;
  timestamp: string;
}
 
// Reusable across all endpoints!
type UserResponse = ApiResponse<User>;
type OrderResponse = ApiResponse<Order[]>;

3. Type Constraints: Not All Types Can Do Everything

Sometimes you don’t want a generic function to accept literally any type. You only want types that support a specific operation (like being compared with < or being converted to a string).

We restrict types using Type Constraints:

package main
 
import "cmp"
 
// cmp.Ordered restricts T to types that support <, <=, >, >= (ints, floats, strings)
func Min[T cmp.Ordered](a, b T) T {
	if a < b {
		return a
	}
	return b
}
 
func main() {
	println(Min(10, 20))       // 10
	println(Min("apple", "banana")) // "apple"
}

4. Rule of Thumb: When Should You Use Generics?

Generics are powerful, but over-using them makes code harder to read.

Good Uses for Generics:

  • Common data structures (queues, caches, trees, linked lists).
  • Generic utility functions operating on slices, maps, or streams (e.g., Map, Filter, Reduce, Contains).
  • Generic API response / repository wrappers.

When NOT to Use Generics:

  • When a simple interface is sufficient.
  • When the function only ever operates on one or two known types in your business logic.

Across the Wiki