Pro Tips — Interfaces and Generics
Interface Design Principles — Keep Them Small
package main
import (
"fmt"
"io"
"strings"
)
// Bad: interface too large
type BadRepository interface {
FindByID(id int) (any, error)
FindAll() ([]any, error)
Create(entity any) error
Update(entity any) error
Delete(id int) error
Count() (int, error)
Search(query string) ([]any, error)
// User must implement all these methods → hard to test
}
// Good: split by responsibility
type Finder interface {
FindByID(id int) (any, error)
}
type Creator interface {
Create(entity any) error
}
type Updater interface {
Update(entity any) error
}
type Deleter interface {
Delete(id int) error
}
// Compose as needed
type ReadRepository interface {
Finder
FindAll() ([]any, error)
}
type WriteRepository interface {
Creator
Updater
Deleter
}
// Go style: define interfaces on the consumer side (the calling package),
// not in the implementation package
// Practical: dependency injection for easy testing
type EmailSender interface {
Send(to, subject, body string) error
}
type UserService struct {
emailer EmailSender
}
func NewUserService(emailer EmailSender) *UserService {
return &UserService{emailer: emailer}
}
func (s *UserService) Register(email string) error {
return s.emailer.Send(email, "Welcome", "Thank you for signing up!")
}
// Test mock
type MockEmailSender struct {
Sent []string
}
func (m *MockEmailSender) Send(to, subject, body string) error {
m.Sent = append(m.Sent, to)
fmt.Printf("[MOCK] email sent → %s\n", to)
return nil
}
// Real implementation
type SMTPSender struct {
host string
}
func (s *SMTPSender) Send(to, subject, body string) error {
fmt.Printf("[SMTP] %s → %s: %s\n", s.host, to, subject)
return nil
}
func main() {
// Test environment
mock := &MockEmailSender{}
svc := NewUserService(mock)
svc.Register("user@example.com")
fmt.Println("sent emails:", mock.Sent)
// Production environment
smtp := &SMTPSender{host: "mail.example.com"}
prodSvc := NewUserService(smtp)
prodSvc.Register("user2@example.com")
// io.Writer is an interface — works anywhere
writers := []io.Writer{
&strings.Builder{},
}
_ = writers
}
The nil Interface Pitfall — Complete Explanation
package main
import "fmt"
type MyError struct {
msg string
}
func (e *MyError) Error() string { return e.msg }
// Common mistake: returning nil pointer as interface
func badFunc(fail bool) error {
var err *MyError // nil *MyError
if fail {
err = &MyError{"it failed"}
}
return err // (*MyError)(nil) → the error interface is NOT nil!
}
// Correct: return nil directly as error
func goodFunc(fail bool) error {
if fail {
return &MyError{"it failed"}
}
return nil // error(nil) — truly nil interface
}
func main() {
// Trap: badFunc(false) looks nil but isn't
err1 := badFunc(false)
fmt.Println("badFunc result:", err1) // <nil>
fmt.Println("badFunc nil check:", err1 == nil) // false! (bug)
err2 := goodFunc(false)
fmt.Println("goodFunc result:", err2) // <nil>
fmt.Println("goodFunc nil check:", err2 == nil) // true
// Understanding interface internals:
// interface = (type pointer, value pointer)
// badFunc: (type=*MyError, value=nil) → NOT nil
// goodFunc: (type=nil, value=nil) → nil
// Another pitfall in practice
var slice []int = nil
var m map[string]int = nil
// These are safe — slices/maps are not interfaces
fmt.Println(slice == nil) // true
fmt.Println(m == nil) // true
// But when stored in any:
var iSlice any = slice
fmt.Println(iSlice == nil) // false! — (type=[]int, value=nil)
}
Generics: Overuse vs. Correct Use
package main
import (
"fmt"
"sort"
)
// Situation 1: interface is more appropriate
// Bad
func GenericProcess[T any](v T) {
fmt.Println(v) // generic is pointless here — any suffices
}
// Good
type Processor interface {
Process() string
}
func InterfaceProcess(p Processor) {
fmt.Println(p.Process())
}
// Situation 2: generics clearly win
// Type-safe collection utilities
func Contains[T comparable](slice []T, item T) bool {
for _, v := range slice {
if v == item {
return true
}
}
return false
}
func Unique[T comparable](slice []T) []T {
seen := make(map[T]struct{})
result := make([]T, 0, len(slice))
for _, v := range slice {
if _, ok := seen[v]; !ok {
seen[v] = struct{}{}
result = append(result, v)
}
}
return result
}
func Chunk[T any](slice []T, size int) [][]T {
if size <= 0 {
return nil
}
var chunks [][]T
for size < len(slice) {
slice, chunks = slice[size:], append(chunks, slice[:size])
}
return append(chunks, slice)
}
// Situation 3: leverage type inference
func Zip[A, B any](as []A, bs []B) []struct{ A A; B B } {
n := len(as)
if len(bs) < n {
n = len(bs)
}
result := make([]struct{ A A; B B }, n)
for i := 0; i < n; i++ {
result[i] = struct{ A A; B B }{as[i], bs[i]}
}
return result
}
func main() {
nums := []int{1, 2, 3, 2, 4, 1, 5, 3}
fmt.Println("Contains 3:", Contains(nums, 3)) // true
fmt.Println("Contains 9:", Contains(nums, 9)) // false
fmt.Println("Unique:", Unique(nums)) // [1 2 3 4 5]
fmt.Println("Chunk(3):", Chunk(nums, 3)) // [[1 2 3] [2 4 1] [5 3]]
strs := []string{"a", "b", "c", "b", "a"}
fmt.Println("Unique strs:", Unique(strs)) // [a b c]
keys := []string{"name", "age", "city"}
vals := []int{1, 30, 2}
zipped := Zip(keys, vals)
for _, z := range zipped {
fmt.Printf("%s: %d\n", z.A, z.B)
}
// sort.Slice is flexible without generics
words := []string{"banana", "apple", "cherry", "date"}
sort.Slice(words, func(i, j int) bool {
return words[i] < words[j]
})
fmt.Println(words)
}
Building Middleware with Interface Composition
package main
import (
"fmt"
"time"
)
// Core interface
type Handler interface {
Handle(req string) string
}
// Function type implementing the interface
type HandlerFunc func(string) string
func (f HandlerFunc) Handle(req string) string {
return f(req)
}
// Middleware chain builder
type MiddlewareChain struct {
handler Handler
middlewares []func(Handler) Handler
}
func NewChain(h Handler) *MiddlewareChain {
return &MiddlewareChain{handler: h}
}
func (c *MiddlewareChain) Use(mw func(Handler) Handler) *MiddlewareChain {
c.middlewares = append(c.middlewares, mw)
return c
}
func (c *MiddlewareChain) Build() Handler {
h := c.handler
// Apply in reverse order (LIFO)
for i := len(c.middlewares) - 1; i >= 0; i-- {
h = c.middlewares[i](h)
}
return h
}
// Middlewares
func LoggingMiddleware(next Handler) Handler {
return HandlerFunc(func(req string) string {
start := time.Now()
result := next.Handle(req)
fmt.Printf("[LOG] req=%q result=%q elapsed=%v\n",
req, result, time.Since(start))
return result
})
}
func CachingMiddleware(next Handler) Handler {
cache := make(map[string]string)
return HandlerFunc(func(req string) string {
if cached, ok := cache[req]; ok {
fmt.Printf("[CACHE HIT] %q\n", req)
return cached
}
result := next.Handle(req)
cache[req] = result
return result
})
}
func RetryMiddleware(maxRetries int) func(Handler) Handler {
return func(next Handler) Handler {
return HandlerFunc(func(req string) string {
for i := 0; i < maxRetries; i++ {
result := next.Handle(req)
if result != "ERROR" {
return result
}
fmt.Printf("[RETRY] attempt %d/%d\n", i+1, maxRetries)
}
return "FAILED"
})
}
}
func main() {
// Base handler
base := HandlerFunc(func(req string) string {
return "processed: " + req
})
// Build middleware chain
handler := NewChain(base).
Use(LoggingMiddleware).
Use(CachingMiddleware).
Build()
// Handle requests
fmt.Println(handler.Handle("hello"))
fmt.Println(handler.Handle("world"))
fmt.Println(handler.Handle("hello")) // cache hit
}
Interface vs Generics — Decision Summary
package main
import "fmt"
// 1. Runtime polymorphism needed? → Interface
type Logger interface {
Log(msg string)
}
// 2. Compile-time type safety needed? → Generics
type TypedSlice[T any] struct {
items []T
}
// 3. Abstracting behavior? → Interface
type Sorter interface {
Sort()
}
// 4. Generalizing data structures? → Generics
type Queue[T any] struct {
items []T
}
func (q *Queue[T]) Enqueue(item T) { q.items = append(q.items, item) }
func (q *Queue[T]) Dequeue() (T, bool) {
if len(q.items) == 0 {
var zero T
return zero, false
}
item := q.items[0]
q.items = q.items[1:]
return item, true
}
// 5. Supporting external package types too? → Interface (just implement it)
// 6. Performance critical? → Generics (no dynamic dispatch overhead of interfaces)
func main() {
q := &Queue[string]{}
q.Enqueue("first")
q.Enqueue("second")
q.Enqueue("third")
for {
item, ok := q.Dequeue()
if !ok {
break
}
fmt.Println(item)
}
}
Core Rules
- Keep interfaces small— 1–3 methods is ideal; single-method interfaces are most powerful
- Define interfaces on the consumer side— in the calling package, not the implementation package
- nil interface pitfall— returning a typed nil pointer as an interface is not a nil interface
- Generics for data structures/algorithms— interface for behavior, generics for type generalization
~Tconstraint— includes user-defined types with the same underlying type- Leverage type inference— usually no need to explicitly specify type parameters
- Avoid generic overuse— if
anyis enough, skip generics; if interface is clearer, use interface