Genéricos en Go: type parameters con ejemplos

1. Descripción general

Desde Go 1.18, puedes escribir funciones y tipos de datos con type parameters. Gracias a esto, no tienes que duplicar la misma lógica para int, float64, string, ni tampoco necesitas hacer type assertions desde interface{}.

Este artículo va desde el problema inicial hasta funciones genéricas, constraints y la notación ~. Luego, tipos genéricos como Stack[T] y Cache[K, V], cómo escribir tests, y errores comunes. Después de leerlo, sabrás cuándo usar generics y cuándo una interface común es suficiente.

Todos los ejemplos usan Go 1.22 o superior. El package cmp está disponible desde Go 1.21. El archivo go.mod mínimo:

module example.com/shop

go 1.22

No necesitas librerías externas. Todo en este artículo está en la standard library.

2. El problema antes de tener genéricos

Supón que necesitas calcular la suma de cantidades (int) y la suma de dinero (float64). Antes de Go 1.18, normalmente escribías dos funciones idénticas:

func SumInts(xs []int) int {
	var total int
	for _, x := range xs {
		total += x
	}
	return total
}

func SumFloats(xs []float64) float64 {
	var total float64
	for _, x := range xs {
		total += x
	}
	return total
}

La otra opción era recibir []interface{} y usar type switch. Esto pierde type safety. Los errores solo aparecen en runtime, y tienes que hacer type assertions en todos los lugares donde llamas la función.

Los genéricos resuelven exactamente esto: escribe la lógica una sola vez, y el compiler sigue verificando los tipos por ti.

3. Tu primera función genérica

3.1. Sintaxis de type parameters

Los type parameters se declaran entre corchetes, justo después del nombre de la función:

package main

import "fmt"

type Number interface {
	~int | ~int64 | ~float64
}

func Sum[T Number](xs []T) T {
	var total T
	for _, x := range xs {
		total += x
	}
	return total
}

func main() {
	quantities := []int{2, 1, 5}
	prices := []float64{120.5, 99.9, 15}

	fmt.Println(Sum(quantities))
	fmt.Println(Sum(prices))
}

Explicación de cada parte:

  • [T Number] declara el type parameter T. T debe satisfacer el constraint Number.
  • Number es una interface que contiene un type set: int, int64, float64 y tipos derivados de ellos (el significado de ~ está en la sección 4.3).
  • var total T crea el zero value de T, es decir, 0 o 0.0.
  • El operador += es válido porque todos los tipos en el type set soportan la suma.

Resultado:

8
235.4

3.2. Inferencia de tipos

¿Notas que llamamos Sum(quantities) en lugar de Sum[int](quantities)? El compiler deduce T automáticamente del argumento. Esto es type inference.

Solo necesitas especificar explícitamente cuando el compiler no tiene suficiente información para deducir, por ejemplo, cuando el type parameter solo aparece en el tipo de retorno:

func Zero[T any]() T {
	var z T
	return z
}

func main() {
	fmt.Println(Zero[string]() == "") // true
}

3.3. Múltiples type parameters: Map y Filter

Estas son dos de las funciones auxiliares más comunes. Las aplicaremos a una lista de Order:

package main

import "fmt"

type Order struct {
	ID       string
	Customer string
	Total    float64
}

func Map[T, U any](xs []T, f func(T) U) []U {
	out := make([]U, 0, len(xs))
	for _, x := range xs {
		out = append(out, f(x))
	}
	return out
}

func Filter[T any](xs []T, keep func(T) bool) []T {
	var out []T
	for _, x := range xs {
		if keep(x) {
			out = append(out, x)
		}
	}
	return out
}

func main() {
	orders := []Order{
		{"A01", "Lan", 250_000},
		{"A02", "Minh", 1_200_000},
		{"A03", "Lan", 890_000},
	}

	big := Filter(orders, func(o Order) bool { return o.Total >= 500_000 })
	ids := Map(big, func(o Order) string { return o.ID })

	fmt.Println(ids)
}
  • Map[T, U any] tiene dos type parameters, por lo que el tipo de entrada y el de salida pueden ser diferentes.
  • any es un alias de interface{}. Permite cualquier tipo, pero no puedes llamar a ningún operador sobre ese valor excepto asignación y paso de parámetros.
  • El compiler deduce T = Order y U = string de la función func(o Order) string.

Resultado:

[A02 A03]

4. Constraints: limita lo que T puede hacer

Un constraint determina qué puedes hacer con un valor de tipo T dentro de la función.

4.1. comparable

comparable es un constraint incorporado. Permite usar == y !=, y también es la condición para que T pueda ser la clave de un map:

func Unique[T comparable](xs []T) []T {
	seen := make(map[T]struct{}, len(xs))
	var out []T
	for _, x := range xs {
		if _, ok := seen[x]; !ok {
			seen[x] = struct{}{}
			out = append(out, x)
		}
	}
	return out
}

// Unique([]string{"Lan", "Minh", "Lan"}) => [Lan Minh]

El struct Order de arriba también es comparable, porque todos sus campos se pueden comparar. Un struct que contiene slice, map o func no satisface comparable, y el compiler reportará un error.

4.2. cmp.Ordered

Cuando necesitas comparaciones de mayor que o menor que, usa cmp.Ordered de la standard library:

import "cmp"

func MaxBy[T any, K cmp.Ordered](xs []T, key func(T) K) (T, bool) {
	var best T
	if len(xs) == 0 {
		return best, false
	}
	best = xs[0]
	for _, x := range xs[1:] {
		if key(x) > key(best) {
			best = x
		}
	}
	return best, true
}

// top, _ := MaxBy(orders, func(o Order) float64 { return o.Total })
// top.ID => "A02"
  • K cmp.Ordered permite usar > en el valor de la clave.
  • La función devuelve (T, bool) para manejar slices vacíos sin necesidad de panic.

Nota: Para slices de números simples, no necesitas escribirlo tú mismo. Go 1.21 ya tiene las funciones built-in max/min, y el package slices tiene slices.Max, slices.Contains, slices.Sort. Revisa slices y maps antes de escribir tus propias utilidades genéricas.

4.3. La notación ~ (tilde)

En un caso real, a menudo defines tipos propios para el dinero:

type VND int64

func main() {
	payments := []VND{150_000, 350_000}
	fmt.Println(Sum(payments)) // 500000
}

Esta línea compila gracias a ~int64 en Number. La notación ~int64 significa “cualquier tipo cuyo underlying type sea int64”. Si escribes int64 sin ~, entonces VND no satisface el constraint, y el compiler reporta:

VND does not satisfy Number (possibly missing ~ for int64 in Number)

Regla simple: cuando escribes constraints para tipos básicos, casi siempre debes usar ~.

4.4. Constraint que combina métodos

Un constraint también puede requerir métodos, al igual que una interface ordinaria:

type Identifiable interface {
	comparable
	Key() string
}

Un tipo que quiera satisfacer Identifiable debe ser comparable y tener el método Key() string.

5. Tipos genéricos

5.1. Stack[T]

Los type parameters no solo se usan en funciones sino también en structs:

type Stack[T any] struct {
	items []T
}

func (s *Stack[T]) Push(v T) {
	s.items = append(s.items, v)
}

func (s *Stack[T]) Pop() (T, bool) {
	var zero T
	if len(s.items) == 0 {
		return zero, false
	}
	last := len(s.items) - 1
	v := s.items[last]
	s.items = s.items[:last]
	return v, true
}

func (s *Stack[T]) Len() int { return len(s.items) }

Cómo usarlo:

var history Stack[Order]
history.Push(Order{ID: "A01"})
history.Push(Order{ID: "A02"})

o, _ := history.Pop()
fmt.Println(o.ID, history.Len()) // A02 1
  • El receiver se escribe como *Stack[T], debe incluir [T].
  • Cuando declares una variable, debes especificar el tipo: Stack[Order]. No hay inferencia en este caso.
  • El zero value de Stack[Order] se puede usar inmediatamente, porque append funciona bien en slices nil.

5.2. Cache[K, V] segura para múltiples goroutines

Un ejemplo más realista: un cache en memoria compartido para Customer y Order.

package cache

import "sync"

type Cache[K comparable, V any] struct {
	mu   sync.RWMutex
	data map[K]V
}

func New[K comparable, V any]() *Cache[K, V] {
	return &Cache[K, V]{data: make(map[K]V)}
}

func (c *Cache[K, V]) Put(key K, value V) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.data[key] = value
}

func (c *Cache[K, V]) Get(key K) (V, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	v, ok := c.data[key]
	return v, ok
}
type Customer struct {
	ID   int
	Name string
}

customers := cache.New[int, Customer]()
customers.Put(1, Customer{ID: 1, Name: "Lan"})

orders := cache.New[string, Order]()
orders.Put("A01", Order{ID: "A01", Total: 250_000})

c, ok := customers.Get(1)
fmt.Println(c.Name, ok) // Lan true

Una implementación que sirve a dos tipos de datos. customers.Get devuelve exactamente Customer, sin necesidad de type assertion. Si accidentalmente intentas Put una Order en customers, el código no compilará.

6. Escribir tests para código genérico

Go usa el package testing estándar. Los tests table-driven funcionan normalmente con funciones genéricas. Cada tabla de test corresponde a un tipo específico:

package main

import "testing"

func TestSum(t *testing.T) {
	ints := []struct {
		name string
		in   []int
		want int
	}{
		{"empty", nil, 0},
		{"positive", []int{2, 1, 5}, 8},
		{"mixed", []int{10, -3}, 7},
	}
	for _, tc := range ints {
		t.Run(tc.name, func(t *testing.T) {
			if got := Sum(tc.in); got != tc.want {
				t.Errorf("Sum(%v) = %d, want %d", tc.in, got, tc.want)
			}
		})
	}

	if got := Sum([]VND{150_000, 350_000}); got != 500_000 {
		t.Errorf("Sum VND = %d, want 500000", got)
	}
}

func TestStackPopEmpty(t *testing.T) {
	var s Stack[string]
	v, ok := s.Pop()
	if ok || v != "" {
		t.Fatalf("Pop() on empty = (%q, %v), want (\"\", false)", v, ok)
	}
}

Ejecutar los tests:

go test ./... -v
=== RUN   TestSum
=== RUN   TestSum/empty
=== RUN   TestSum/positive
=== RUN   TestSum/mixed
--- PASS: TestSum (0.00s)
=== RUN   TestStackPopEmpty
--- PASS: TestStackPopEmpty (0.00s)
PASS

Asegúrate de probar al menos un tipo derivado como VND. Esta es la forma más rápida de detectar si falta ~ en un constraint.

7. Errores comunes y cuándo no usar genéricos

Los métodos no pueden tener type parameters propios. El siguiente código no compila:

// error: methods cannot have type parameters
func (s *Stack[T]) Map[U any](f func(T) U) *Stack[U] { ... }

Escríbelo como una función de nivel superior: func MapStack[T, U any](s *Stack[T], f func(T) U) *Stack[U].

No hagas type switch directamente sobre T. Para ramificar según el tipo, primero tienes que convertir a any: switch v := any(x).(type) { ... }. Si encuentras que haces esto frecuentemente, probablemente los genéricos no sean la herramienta adecuada.

Un constraint con type set no puede usarse como tipo de variable. var n Number reportará un error. Las interfaces que contienen | o ~ solo se pueden usar como constraints.

comparable con una interface puede hacer panic. Si K es un tipo interface y el valor dinámico dentro es un slice, la comparación o usarlo como clave de map hará panic en runtime.

Nota: Los genéricos no son automáticamente más rápidos que las interfaces. El compiler de Go usa “GC shape stenciling” con un diccionario. Diferentes tipos de punteros pueden compartir el mismo código, y las llamadas a métodos a través de constraints todavía tienen un costo indirecto. Si el rendimiento es importante, mide con benchmarks en lugar de adivinar.

Cuándo NO usar genéricos:

  • Cuando la lógica depende del comportamiento (métodos), no del tipo. Por ejemplo, PaymentService recibe un Notifier con un método Send. Las interfaces suelen ser más concisas y legibles.
  • Cuando solo hay un tipo específico que usa esa función. No hagas genéricos “por si acaso en el futuro los necesites”.
  • Cuando slices, maps, cmp ya tienen la función que necesitas.

Una regla fácil de recordar: si escribes el mismo código para varios tipos, y solo difiere en el nombre del tipo, ese es el momento de usar genéricos.

8. Conclusión

Este artículo ha cubierto las partes principales de los genéricos en Go:

  • Declarar type parameters [T C] y dejar que el compiler deduzca el tipo automáticamente.
  • Usar constraints any, comparable, cmp.Ordered, y definir tus propios constraints con ~ para soportar tipos derivados como VND.
  • Construir tipos genéricos Stack[T] y Cache[K, V] seguros para múltiples goroutines.
  • Escribir tests table-driven para funciones genéricas.
  • Identificar las limitaciones: los métodos no pueden tener type parameters propios, los constraints no son tipos de variables, y los genéricos no reemplazan las interfaces.

Empieza donde realmente se repite el código en tu proyecto. Escribe genéricos al mínimo necesario, prioriza la standard library, y siempre incluye tests para al menos dos tipos diferentes.

golanggenericstype parameterconstraint