Generics vs Interfaces — Find the Bug¶
How to use¶
Each problem shows a code snippet. Read it carefully and answer: 1. What is the bug? 2. How would you fix it? 3. Was the wrong abstraction (generic vs interface) part of the cause?
Solutions are at the end. Most bugs come from picking the wrong tool — over-abstracted generics, lost dynamic dispatch, or hidden allocations.
Bug 1 — Over-abstracted generic API¶
func Notify[T any](v T, msg string) error {
switch x := any(v).(type) {
case Email: return x.Send(msg)
case Slack: return x.Send(msg)
case SMS: return x.Send(msg)
}
return errors.New("unknown notifier")
}
Hint: What kind of polymorphism does the type switch reveal?
Bug 2 — Lost dynamic dispatch¶
type Reader interface { Read([]byte) (int, error) }
func ReadAll[R Reader](r R) ([]byte, error) {
var buf [4096]byte
var out []byte
for {
n, err := r.Read(buf[:])
out = append(out, buf[:n]...)
if err == io.EOF { return out, nil }
if err != nil { return out, err }
}
}
readers := []Reader{file1, conn, bytesReader}
for _, r := range readers {
ReadAll(r) // ?
}
Hint: What T does the compiler pick when r is an interface variable?
Bug 3 — Hidden allocations from []Interface¶
type Logger interface { Log(string) }
type StdLogger struct{}
func (StdLogger) Log(msg string) { fmt.Println(msg) }
func LogAll(loggers []Logger, msg string) {
for _, l := range loggers { l.Log(msg) }
}
// 1 million times:
LogAll([]Logger{StdLogger{}}, "ping")
Hint: Building a []Logger literal — what happens to each StdLogger{}?
Bug 4 — Single-implementation interface¶
type UserRepo interface {
Find(id int) (*User, error)
Save(u *User) error
}
type pgUserRepo struct{ db *sql.DB }
// Only implementation in the project. No tests use a fake.
Hint: What is the cost vs the benefit of this interface?
Bug 5 — Generic where interface was needed¶
func Render[T any](items []T) string {
var sb strings.Builder
for _, v := range items {
if r, ok := any(v).(Renderable); ok {
sb.WriteString(r.Render())
}
}
return sb.String()
}
Hint: Why is the any(v).(Renderable) runtime check there?
Bug 6 — Heterogeneous slice attempted with generics¶
type Stack[T any] struct{ data []T }
var s Stack[any] // workaround for heterogeneous storage
s.data = append(s.data, 1, "hi", true)
Hint: What did T any accomplish here?
Bug 7 — Public API leaks generics¶
package userlib
func Find[T User | AdminUser](id int) (*T, error) { ... }
// caller code:
u, err := userlib.Find[userlib.User](42)
a, err := userlib.Find[userlib.AdminUser](42)
Hint: What happens when the library wants to add GuestUser?
Bug 8 — Forgetting that error is an interface¶
Hint: Can you assign *MyError to error directly? What does the snippet really do?
Bug 9 — Interface in hot path¶
type Adder interface { Add(int) int }
type Counter struct{ n int }
func (c *Counter) Add(d int) int { c.n += d; return c.n }
var counters []Adder
for i := 0; i < 1_000_000_000; i++ {
counters[i%len(counters)].Add(1)
}
Hint: Profile shows the loop is dispatch-bound. What is the fix?
Bug 10 — Constraint that should be an interface¶
type Notifier interface {
Email | Slack | SMS
Notify(string) error
}
func Alert[T Notifier](n T, msg string) error { return n.Notify(msg) }
// Six months later, a new `Discord` notifier is needed.
Hint: Can Discord be added without modifying the constraint?
Bug 11 — Generic with type assertion inside¶
func Decode[T any](data []byte) (T, error) {
var v T
if err := json.Unmarshal(data, &v); err != nil {
var zero T
return zero, err
}
if validator, ok := any(v).(interface{ Validate() error }); ok {
if err := validator.Validate(); err != nil {
var zero T
return zero, err
}
}
return v, nil
}
Hint: The code works, but the type assertion hides intent. What is a cleaner alternative?
Bug 12 — Lost type info through any¶
func Cache(key string, fn func() any) any {
if v, ok := store.Load(key); ok { return v }
v := fn()
store.Store(key, v)
return v
}
result := Cache("user:42", func() any { return loadUser(42) }).(*User)
Hint: Why is the (*User) assertion at the call site dangerous?
Bug 13 — Generic interface that should be a method-set interface¶
type Comparable[T any] interface {
Equal(other T) bool
}
func Distinct[T Comparable[T]](items []T) []T {
var out []T
for _, v := range items {
seen := false
for _, w := range out { if v.Equal(w) { seen = true; break } }
if !seen { out = append(out, v) }
}
return out
}
Hint: What is the cost of self-referential type parameters here?
Bug 14 — []any instead of typed slice¶
func Sum(s []any) float64 {
var total float64
for _, v := range s {
switch x := v.(type) {
case int: total += float64(x)
case float64: total += x
}
}
return total
}
Hint: Two problems — boxing and silent skipping.
Bug 15 — Mixing styles in one function¶
type Saver interface { Save() error }
func Save[T Saver](items []T) error {
for _, v := range items {
var s any = v
if saver, ok := s.(Saver); ok {
if err := saver.Save(); err != nil { return err }
}
}
return nil
}
Hint: The constraint already guarantees the method. What is the assertion doing?
Solutions¶
Bug 1 — fix¶
The type switch reveals real polymorphism. Use an interface:
type Notifier interface { Send(msg string) error }
func Notify(n Notifier, msg string) error { return n.Send(msg) }
switch any(v).(type) inside a generic is interface dispatch in disguise. Make the abstraction explicit. Bug 2 — fix¶
When r is Reader (an interface variable), the compiler picks T = Reader. The "generic" call is just an interface call, plus dictionary indirection. There is no win. Drop generics here:
Bug 3 — fix¶
Each StdLogger{} is boxed into a Logger header. For a million calls, that is a million heap allocations. Pass typed:
Bug 4 — fix¶
A single-implementation interface is noise. Inline the concrete type. Add the interface only when a second implementation arrives (often for tests):
Lesson: "Interface for everything" is an anti-pattern in modern Go.Bug 5 — fix¶
The body needs Renderable semantics — make it a real interface:
Bug 6 — fix¶
Stack[any] defeats the point of a generic Stack. Either use an interface for genuinely heterogeneous data:
Stack[int] and Stack[string] for homogeneous data. Generics do not enable heterogeneity. Bug 7 — fix¶
The constraint User | AdminUser is closed. Adding GuestUser is a breaking change to every caller. Use an interface:
Bug 8 — fix¶
*MyError does satisfy error if it has Error() string. The cast Result(0).(error) is wrong because Result returns two values. Idiomatic Go uses standard (value, error):
error with generics. Bug 9 — fix¶
For 1B calls, dispatch overhead matters. Specialize:
type Counter struct{ n int }
func (c *Counter) Add(d int) int { c.n += d; return c.n }
counters := []*Counter{...}
for i := 0; i < 1_000_000_000; i++ { counters[i%len(counters)].Add(1) }
Bug 10 — fix¶
The constraint Email | Slack | SMS closes the type set. Use an interface:
type Notifier interface { Notify(string) error }
func Alert(n Notifier, msg string) error { return n.Notify(msg) }
Discord requires no change to Alert. Bug 11 — fix¶
Make the optional method an interface and accept T Validator (or have two functions):
type Validator interface { Validate() error }
func DecodeAndValidate[T Validator](data []byte) (T, error) {
var v T
if err := json.Unmarshal(data, &v); err != nil { var zero T; return zero, err }
if err := v.Validate(); err != nil { var zero T; return zero, err }
return v, nil
}
Bug 12 — fix¶
The any cache loses type safety. Use a generic cache:
func Cache[K comparable, V any](store *sync.Map, key K, fn func() V) V {
if v, ok := store.Load(key); ok { return v.(V) }
v := fn()
store.Store(key, v)
return v
}
result := Cache(store, "user:42", func() *User { return loadUser(42) })
Bug 13 — fix¶
Self-referential generic interfaces (Comparable[T]) are heavy. Compare via cmp.Compare or ==:
func Distinct[T comparable](items []T) []T {
seen := map[T]struct{}{}
var out []T
for _, v := range items {
if _, ok := seen[v]; !ok { seen[v] = struct{}{}; out = append(out, v) }
}
return out
}
comparable if you can; reach for self-referential constraints only when truly necessary. Bug 14 — fix¶
Generic + numeric constraint:
type Number interface { ~int | ~float64 }
func Sum[T Number](s []T) T {
var total T
for _, v := range s { total += v }
return total
}
Bug 15 — fix¶
T Saver already guarantees the method. Drop the assertion:
func Save[T Saver](items []T) error {
for _, v := range items {
if err := v.Save(); err != nil { return err }
}
return nil
}
Lessons¶
Patterns from these bugs:
switch any(v).(type)inside a generic is interface dispatch in disguise. Make it an interface.- Generics over an interface variable do not help. The dispatch stays dynamic.
[]Interfaceallocates heap headers. Use a typed slice for hot paths.- Single-implementation interfaces are noise. Add interfaces when needed, not preemptively.
- Closed constraints with type unions are not extensible. Use interfaces for open extensibility.
- Heterogeneous storage is interface-only. Generics cannot do
[]MultiType. - Generic public APIs leak type parameters everywhere. Treat them like a permanent commitment.
anyplus type assertions is the pre-1.18 anti-pattern. Generics replace it.- Self-referential generic interfaces are expensive. Use
comparableorcmp.Orderedfirst. - The constraint already guarantees the method. Do not re-assert at runtime.
A senior engineer reads each bug as a signal of which abstraction was wrong. The fix is rarely "tweak the syntax"; it is "swap the tool".