Generics vs Interfaces — Tasks¶
Exercise structure¶
- 🟢 Easy — for beginners
- 🟡 Medium — middle level
- 🔴 Hard — senior level
- 🟣 Expert — professional level
A solution for each exercise is provided at the end. Each task asks you to convert between styles or justify a design decision.
Easy 🟢¶
Task 1 — Pick the right tool¶
You are given a function that prints anything in CSV format. Different types format differently (numbers vs dates vs strings). Should this be generic or interface-based? Justify.
Task 2 — Convert interface to generic¶
func Contains(s []interface{}, target interface{}) bool {
for _, v := range s { if v == target { return true } }
return false
}
Task 3 — Convert generic to interface¶
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)
}
return errors.New("unknown")
}
Task 4 — Heterogeneous slice¶
You need a slice that holds both Circle and Square values. Write the type. Could generics do this?
Task 5 — Same body, many types¶
Write a function that returns the first non-zero value from a slice. Choose generics or interface and justify.
Medium 🟡¶
Task 6 — Plugin registry¶
Design a plugin registry that maps plugin names to implementations. Each plugin has an Init and a Run method. Choose the right tool.
Task 7 — Type-safe cache¶
Convert this to a typed API:
type Cache struct { m map[string]interface{} }
func (c *Cache) Get(k string) interface{} { return c.m[k] }
func (c *Cache) Set(k string, v interface{}) { c.m[k] = v }
Task 8 — Generic over interface¶
Write func Join[T fmt.Stringer](items []T, sep string) string that joins the string representations of items with sep. Why is this better than a non-generic func Join(items []fmt.Stringer, sep string) string?
Task 9 — Repository pattern¶
Design a Repository[T] and an interface Repository (non-generic). Compare ergonomics of both for a User type.
Task 10 — Convert sort.Interface user¶
You have a struct that implements sort.Interface:
type byAge []Person
func (b byAge) Len() int { return len(b) }
func (b byAge) Less(i, j int) bool { return b[i].Age < b[j].Age }
func (b byAge) Swap(i, j int) { b[i], b[j] = b[j], b[i] }
sort.Sort(byAge(people))
slices.SortFunc. Discuss the tradeoffs. Task 11 — Notification system¶
Design a notification system that supports email, Slack, and SMS. Should the channels be interface-shaped or generic? Why?
Task 12 — Event bus¶
Design Bus[T any] for type-safe event publishing. Justify why generics are right here.
Task 13 — Validation pipeline¶
A validation pipeline takes a value and runs many rules over it. Each rule may be different per type. Design the API. Generic, interface, or both?
Task 14 — Atomic value¶
Wrap sync/atomic.Value so callers do not need a type assertion. Use generics. Compare with the non-generic version.
Hard 🔴¶
Task 15 — Wrong abstraction¶
Given the snippet, identify whether it should use generics or interfaces, then refactor:
type Stringer interface { String() string }
func Print(items []Stringer) {
for _, v := range items { fmt.Println(v.String()) }
}
Print([]Stringer{User{...}, Product{...}}) // boxes
Print[T Stringer](items []T) or stay interface? When does each win? Task 16 — Mixed system¶
Design a workflow engine that supports many step types (HTTP, DB, custom). Each step runs differently, but the engine treats them uniformly. Use a hybrid generic-plus-interface approach.
Task 17 — Migrate a public API¶
A library exports func Sort(data Interface) accepting sort.Interface. Plan a migration to a generic API while keeping backwards compatibility. Outline the steps.
Task 18 — Decide on a hot path¶
A Find function is called 10 million times per second over a slice of structs. Compare: - func Find(s []sortable, target sortable) int (interface) - func Find[T comparable](s []T, target T) int (generic) Which would you pick and why? What benchmarks would you run?
Task 19 — DI with generics¶
You read about a "fully generic dependency injection container" type Container[T any]. Argue for or against using it in a real Go service.
Expert 🟣¶
Task 20 — Hybrid type-safe pipeline¶
Design a pipeline Pipeline[I, O any] that can be chained like p.Then(f1).Then(f2). Compare with an interface-shaped Stage design. Where do generics shine? Where do interfaces win?
Task 21 — Library boundary¶
You are designing a public library for caching. Should the public API expose Cache[K, V] (generic) or Cache (interface returning any)? Discuss callers, performance, and evolution.
Task 22 — Replace runtime type switch¶
You see this in production:
func Encode(v any) ([]byte, error) {
switch x := v.(type) {
case int: return encodeInt(x), nil
case string: return encodeString(x), nil
case []byte: return x, nil
default: return nil, errors.New("unsupported")
}
}
Solutions¶
Solution 1¶
Interface. The behaviour (formatting) varies per type. Generics would not help because the body is genuinely different per type.
Solution 2¶
func Contains[T comparable](s []T, target T) bool {
for _, v := range s { if v == target { return true } }
return false
}
Contains([]int{1,2,3}, "1") becomes a compile error instead of a silent false. Solution 3¶
type Notifier interface { Send(msg string) error }
func Notify(n Notifier, msg string) error { return n.Send(msg) }
Notify. The generic-with-type-switch was an interface in disguise. Solution 4¶
Generics cannot —[]T is homogeneous. Interfaces are the only option for heterogeneous slices. Solution 5¶
Generic — the body (return first non-zero) is identical for any comparable type:
func FirstNonZero[T comparable](s []T) T {
var zero T
for _, v := range s { if v != zero { return v } }
return zero
}
cmp.Or(s...) exists for similar use. Solution 6¶
Interface. Plugins are unknown at compile time and may be added by third parties.
type Plugin interface {
Init(cfg map[string]any) error
Run(ctx context.Context) error
}
var registry = map[string]Plugin{}
T, which defeats the plugin idea. Solution 7¶
type Cache[K comparable, V any] struct { m map[K]V }
func (c *Cache[K, V]) Get(k K) (V, bool) { v, ok := c.m[k]; return v, ok }
func (c *Cache[K, V]) Set(k K, v V) { c.m[k] = v }
Solution 8¶
func Join[T fmt.Stringer](items []T, sep string) string {
parts := make([]string, len(items))
for i, v := range items { parts[i] = v.String() }
return strings.Join(parts, sep)
}
[]User, not []Stringer). No boxing. The compiler may inline String() for known types. Interface version requires callers to first build a []Stringer from their concrete slice, which boxes every element. Solution 9¶
// Generic
type Repository[T any] interface {
Find(id int) (*T, error)
Save(v *T) error
}
// Non-generic (one repo per aggregate)
type UserRepository interface {
Find(id int) (*User, error)
Save(u *User) error
}
Solution 10¶
Tradeoffs: less boilerplate (no three methods), faster (comparator inlinable), requires Go 1.21+. Thesort.Interface form lets you implement custom orderings without exporting a closure but is rarely a meaningful win. Solution 11¶
Interface. Each channel sends differently:
type Notifier interface { Send(to, msg string) error }
type Email struct{}; func (Email) Send(to, msg string) error { ... }
type Slack struct{}; func (Slack) Send(to, msg string) error { ... }
Solution 12¶
type Bus[T any] struct {
subs []func(T)
}
func (b *Bus[T]) Subscribe(f func(T)) { b.subs = append(b.subs, f) }
func (b *Bus[T]) Publish(v T) { for _, f := range b.subs { f(v) } }
evt.(MyEvent) assertion). Solution 13¶
Hybrid:
type Rule[T any] interface { Validate(v T) error }
func Validate[T any](v T, rules ...Rule[T]) error {
for _, r := range rules { if err := r.Validate(v); err != nil { return err } }
return nil
}
Rule[T] is a generic interface. The validator function is generic. Each concrete Rule implementation does different validation logic. Solution 14¶
type Atomic[T any] struct { v atomic.Value }
func (a *Atomic[T]) Store(v T) { a.v.Store(v) }
func (a *Atomic[T]) Load() (T, bool) {
v := a.v.Load()
if v == nil { var zero T; return zero, false }
return v.(T), true
}
atomic.Value, callers no longer need v.(MyType). Solution 15¶
Print[T Stringer](items []T) is better when callers naturally have a []User (homogeneous). It avoids boxing. The interface form Print(items []Stringer) is better when callers need a heterogeneous slice. In the snippet above, the slice is heterogeneous, so the interface form is correct.
Solution 16¶
type Step interface {
Run(ctx context.Context, in any) (any, error)
}
func Execute[T Step](ctx context.Context, steps []T, input any) (any, error) {
cur := input
for _, s := range steps {
out, err := s.Run(ctx, cur)
if err != nil { return nil, err }
cur = out
}
return cur, nil
}
Solution 17¶
- Add
func SortSlice[T cmp.Ordered](s []T)alongsideSort. - Mark
Sortas// Deprecated: use SortSlice(eventually). - Promote
SortSlicein docs and examples. - Retire
Sortonly in a major-version bump (semver/v2). This mirrors how the stdlib addedslices.Sortalongsidesort.Sort.
Solution 18¶
Generic. On a 10M-call hot path, interface dispatch overhead is real. Benchmark:
func BenchmarkFindIface(b *testing.B) { for i := 0; i < b.N; i++ { findIface(s, t) } }
func BenchmarkFindGen(b *testing.B) { for i := 0; i < b.N; i++ { findGen(s, t) } }
go test -bench=. -benchmem and compare ns/op and allocs/op. Generic should win on both. Solution 19¶
Argue against. A generic DI container forces every consumer to know about T, which defeats DI's point of "the consumer does not know which implementation". Use plain interface DI; it is what every Go DI library does.
Solution 20¶
type Pipeline[I, O any] struct { fn func(I) O }
func New[I, O any](f func(I) O) Pipeline[I, O] { return Pipeline[I, O]{fn: f} }
// Note: chaining .Then[X any] is not directly possible because methods cannot have type parameters.
// Workaround: use a free function.
func Then[I, O, X any](p Pipeline[I, O], next func(O) X) Pipeline[I, X] {
return Pipeline[I, X]{fn: func(in I) X { return next(p.fn(in)) }}
}
type Stage interface { Run(any) any }) wins for late-binding stages that may be added at runtime. Solution 21¶
A common modern answer: expose Cache[K, V] for new code, and provide a small type AnyCache interface { Get(any) (any, bool); Set(any, any) } adapter for legacy callers. This gives type safety without losing the heterogeneous escape hatch.
Solution 22¶
The right tool depends on extension model: - If callers add new types: interface.
type Encoder interface { Encode() ([]byte, error) }
func Encode(v Encoder) ([]byte, error) { return v.Encode() }
type Encodable interface { ~int | ~string | ~[]byte }
func Encode[T Encodable](v T) ([]byte, error) { ... }
switch v.(type) form works but is a smell — it hides interface dispatch in unstructured code. Final notes¶
The recurring lesson: generics replace interface{} (the workaround); interfaces stay for genuine polymorphism. Every solution here can be defended by the one-line rule: same body → generics, different bodies → interfaces. Practice converting between styles until the choice is automatic.