interfaces-generics.md 16 KB

Go Interfaces and Generics Reference

Table of Contents

  1. Interface Design Principles
  2. Interface Composition
  3. Type Assertions
  4. Empty Interface and any
  5. Generics Basics
  6. Generic Functions
  7. Generic Types
  8. Constraints
  9. When NOT to Use Generics
  10. Functional Options
  11. Builder Pattern
  12. Strategy via Interfaces

1. Interface Design Principles

Accept interfaces, return concrete types. Callers decide what abstraction they need; implementations should not hide their type behind an interface at the return site.

// BAD: returns interface, hides the concrete type unnecessarily
func NewStore() Store {
    return &postgresStore{}
}

// GOOD: returns concrete pointer; callers that need the interface accept it
func NewStore() *PostgresStore {
    return &postgresStore{}
}

Keep interfaces small. One or two methods is the ideal. Large interfaces are hard to mock and hard to satisfy.

// BAD: one interface does too much
type UserService interface {
    GetUser(id int64) (*User, error)
    CreateUser(u *User) error
    DeleteUser(id int64) error
    SendWelcomeEmail(u *User) error
    AuditLog(action string) error
}

// GOOD: split by role
type UserReader interface {
    GetUser(id int64) (*User, error)
}

type UserWriter interface {
    CreateUser(u *User) error
    DeleteUser(id int64) error
}

type Notifier interface {
    SendWelcomeEmail(u *User) error
}

Define interfaces at the point of use (consumer), not the provider. This avoids import cycles and keeps packages decoupled.

Standard library examples of well-sized interfaces:

// io package: one method each
type Reader interface { Read(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }
type Closer interface { Close() error }

// fmt package: one method
type Stringer interface { String() string }

// sort package: three methods (minimum needed for the algorithm)
type Interface interface {
    Len() int
    Less(i, j int) bool
    Swap(i, j int)
}

2. Interface Composition

Embed smaller interfaces to build larger ones. Only embed what callers genuinely need together.

// Compose from stdlib primitives
type ReadWriter interface {
    io.Reader
    io.Writer
}

type ReadWriteCloser interface {
    io.Reader
    io.Writer
    io.Closer
}

// Compose from your own interfaces
type Repository interface {
    UserReader
    UserWriter
}

// Satisfy a composed interface with one struct
type postgresStore struct{ db *sql.DB }

func (s *postgresStore) GetUser(id int64) (*User, error)    { /* ... */ }
func (s *postgresStore) CreateUser(u *User) error           { /* ... */ }
func (s *postgresStore) DeleteUser(id int64) error          { /* ... */ }

var _ Repository = (*postgresStore)(nil) // compile-time check

The blank identifier assignment var _ Repository = (*postgresStore)(nil) is a zero-cost compile-time assertion that *postgresStore satisfies Repository.


3. Type Assertions

Single-Value Form (Panics on Failure)

Use only when you are certain of the type, such as immediately after a type switch.

var v interface{} = "hello"
s := v.(string) // panics if v is not a string

Comma-OK Form (Safe)

var v interface{} = "hello"

s, ok := v.(string)
if !ok {
    // handle wrong type
}

Type Switch

The idiomatic way to branch on dynamic type. The variable x is narrowed to the concrete type in each case.

func describe(v interface{}) string {
    switch x := v.(type) {
    case string:
        return fmt.Sprintf("string of length %d", len(x))
    case int:
        return fmt.Sprintf("int: %d", x)
    case []byte:
        return fmt.Sprintf("bytes: %x", x)
    case fmt.Stringer:
        return fmt.Sprintf("stringer: %s", x.String())
    case nil:
        return "nil"
    default:
        return fmt.Sprintf("unknown type: %T", x)
    }
}

4. Empty Interface and any

any is an alias for interface{} introduced in Go 1.18. Prefer any in new code.

Use any only when the type is genuinely unknown at compile time: codec targets, generic containers before generics were available, or variadic logging arguments.

// Legitimate: JSON decode target unknown at call site
func Decode(r io.Reader, dst any) error {
    return json.NewDecoder(r).Decode(dst)
}

// Legitimate: structured logging with arbitrary fields
func Info(msg string, fields ...any) { /* ... */ }

// Avoid: using any when a concrete type or interface would work
func Process(v any) {         // BAD if callers always pass *User
    u := v.(*User)            // forced assertion everywhere
}

func Process(u *User) { /* ... */ } // GOOD

Do not use any as a way to avoid thinking about types. Every any is a deferred type error waiting for runtime.


5. Generics Basics

Go generics use type parameters in square brackets. Introduced in Go 1.18.

// Type parameter T with constraint comparable
func Contains[T comparable](slice []T, item T) bool {
    for _, v := range slice {
        if v == item {
            return true
        }
    }
    return false
}

// Usage - type inferred from arguments
found := Contains([]string{"a", "b", "c"}, "b") // true
found = Contains([]int{1, 2, 3}, 4)              // false

// Multiple type parameters
func Map[K comparable, V any](m map[K]V, f func(V) V) map[K]V {
    out := make(map[K]V, len(m))
    for k, v := range m {
        out[k] = f(v)
    }
    return out
}

Type inference works in most cases. Provide explicit type arguments only when the compiler cannot infer them.

// Explicit type argument needed when return type differs from arguments
func Zero[T any]() T {
    var zero T
    return zero
}

z := Zero[int]()    // must be explicit: no argument to infer from

6. Generic Functions

Filter, Map, Reduce

func Filter[T any](slice []T, predicate func(T) bool) []T {
    var result []T
    for _, v := range slice {
        if predicate(v) {
            result = append(result, v)
        }
    }
    return result
}

func Map[T, U any](slice []T, f func(T) U) []U {
    result := make([]U, len(slice))
    for i, v := range slice {
        result[i] = f(v)
    }
    return result
}

func Reduce[T, U any](slice []T, initial U, f func(U, T) U) U {
    acc := initial
    for _, v := range slice {
        acc = f(acc, v)
    }
    return acc
}

// Keys returns the keys of a map in unspecified order
func Keys[K comparable, V any](m map[K]V) []K {
    keys := make([]K, 0, len(m))
    for k := range m {
        keys = append(keys, k)
    }
    return keys
}

// Values returns the values of a map in unspecified order
func Values[K comparable, V any](m map[K]V) []V {
    values := make([]V, 0, len(m))
    for _, v := range m {
        values = append(values, v)
    }
    return values
}

7. Generic Types

Stack

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

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

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

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

Result Type

Encode success or failure without error returns scattered through call sites.

type Result[T any] struct {
    value T
    err   error
}

func Ok[T any](value T) Result[T]    { return Result[T]{value: value} }
func Err[T any](err error) Result[T] { return Result[T]{err: err} }

func (r Result[T]) Unwrap() (T, error) { return r.value, r.err }

func (r Result[T]) Must() T {
    if r.err != nil {
        panic(r.err)
    }
    return r.value
}

Generic Cache with TTL

type entry[V any] struct {
    value     V
    expiresAt time.Time
}

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

func NewCache[K comparable, V any](ttl time.Duration) *Cache[K, V] {
    return &Cache[K, V]{data: make(map[K]entry[V]), ttl: ttl}
}

func (c *Cache[K, V]) Set(key K, value V) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[key] = entry[V]{value: value, expiresAt: time.Now().Add(c.ttl)}
}

func (c *Cache[K, V]) Get(key K) (V, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    e, ok := c.data[key]
    if !ok || time.Now().After(e.expiresAt) {
        var zero V
        return zero, false
    }
    return e.value, true
}

8. Constraints

Built-In Constraints

// comparable: supports == and != (maps, channels, basic types, structs of comparable fields)
func Index[T comparable](slice []T, item T) int {
    for i, v := range slice {
        if v == item {
            return i
        }
    }
    return -1
}

// any: no constraint, widest possible
func Ptr[T any](v T) *T { return &v }

golang.org/x/exp/constraints

import "golang.org/x/exp/constraints"

// Ordered: all types that support <, <=, >, >=
func Min[T constraints.Ordered](a, b T) T {
    if a < b {
        return a
    }
    return b
}

func Max[T constraints.Ordered](a, b T) T {
    if a > b {
        return a
    }
    return b
}

func Clamp[T constraints.Ordered](v, lo, hi T) T {
    return Min(Max(v, lo), hi)
}

Custom Constraints

// Union of specific types
type Integer interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64
}

type Float interface {
    ~float32 | ~float64
}

type Number interface {
    Integer | Float
}

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

Tilde (~) for Underlying Types

~T includes all types whose underlying type is T. Without ~, named types are excluded.

type Celsius float64
type Fahrenheit float64

// Without ~: Celsius and Fahrenheit do not satisfy Float
type Float interface { float32 | float64 }

// With ~: Celsius and Fahrenheit satisfy ~float64
type Float interface { ~float32 | ~float64 }

func Convert[T ~float64](v T) T { return v * 9 / 5 + 32 }

c := Celsius(100)
f := Convert(c) // works because ~float64 includes Celsius

9. When NOT to Use Generics

Use an interface when behavior varies by type. Generics parametrize over structure, not behavior. If the algorithm calls different methods depending on the type, use an interface.

// BAD: generics cannot help here - behavior is type-specific
func Process[T any](v T) {
    // Cannot call v.Serialize() without a constraint defining it
}

// GOOD: interface captures the varying behavior
type Processor interface {
    Process() error
}
func Run(p Processor) error { return p.Process() }

Use a concrete type when you only have one type. Adding a type parameter for a function that only ever handles string or int adds noise with no benefit.

// Unnecessary generics
func ParseInt[T ~string](s T) (int64, error) {
    return strconv.ParseInt(string(s), 10, 64)
}

// Simpler and clearer
func ParseInt(s string) (int64, error) {
    return strconv.ParseInt(s, 10, 64)
}

Prefer any + type switch for heterogeneous collections where types are enumerable and fixed. Generics do not simplify this case.


10. Functional Options

The functional options pattern gives constructors optional, named parameters with default values and forward compatibility.

type Server struct {
    host    string
    port    int
    timeout time.Duration
    maxConn int
    logger  *slog.Logger
}

type Option func(*Server) error

func WithHost(host string) Option {
    return func(s *Server) error {
        if host == "" {
            return errors.New("host cannot be empty")
        }
        s.host = host
        return nil
    }
}

func WithPort(port int) Option {
    return func(s *Server) error {
        if port < 1 || port > 65535 {
            return fmt.Errorf("invalid port: %d", port)
        }
        s.port = port
        return nil
    }
}

func WithTimeout(d time.Duration) Option {
    return func(s *Server) error {
        if d <= 0 {
            return errors.New("timeout must be positive")
        }
        s.timeout = d
        return nil
    }
}

func WithLogger(l *slog.Logger) Option {
    return func(s *Server) error {
        s.logger = l
        return nil
    }
}

func NewServer(opts ...Option) (*Server, error) {
    s := &Server{ // defaults
        host:    "localhost",
        port:    8080,
        timeout: 30 * time.Second,
        maxConn: 100,
        logger:  slog.Default(),
    }
    for _, opt := range opts {
        if err := opt(s); err != nil {
            return nil, fmt.Errorf("applying option: %w", err)
        }
    }
    return s, nil
}

// Usage
srv, err := NewServer(
    WithHost("0.0.0.0"),
    WithPort(9090),
    WithTimeout(time.Minute),
)

11. Builder Pattern

Use when construction requires many steps and partial construction is meaningful.

type QueryBuilder struct {
    table   string
    columns []string
    where   []string
    orderBy string
    limit   int
    args    []any
    err     error // carry errors through the chain
}

func NewQuery(table string) *QueryBuilder {
    if table == "" {
        return &QueryBuilder{err: errors.New("table name required")}
    }
    return &QueryBuilder{table: table, columns: []string{"*"}}
}

func (q *QueryBuilder) Select(cols ...string) *QueryBuilder {
    if q.err != nil {
        return q
    }
    q.columns = cols
    return q
}

func (q *QueryBuilder) Where(condition string, args ...any) *QueryBuilder {
    if q.err != nil {
        return q
    }
    q.where = append(q.where, condition)
    q.args = append(q.args, args...)
    return q
}

func (q *QueryBuilder) OrderBy(col string) *QueryBuilder {
    if q.err != nil {
        return q
    }
    q.orderBy = col
    return q
}

func (q *QueryBuilder) Limit(n int) *QueryBuilder {
    if q.err != nil {
        return q
    }
    if n < 0 {
        q.err = fmt.Errorf("limit must be non-negative, got %d", n)
        return q
    }
    q.limit = n
    return q
}

func (q *QueryBuilder) Build() (string, []any, error) {
    if q.err != nil {
        return "", nil, q.err
    }
    // assemble SQL from q.table, q.columns, q.where, q.orderBy, q.limit
    sql := fmt.Sprintf("SELECT %s FROM %s", strings.Join(q.columns, ", "), q.table)
    if len(q.where) > 0 {
        sql += " WHERE " + strings.Join(q.where, " AND ")
    }
    if q.orderBy != "" {
        sql += " ORDER BY " + q.orderBy
    }
    if q.limit > 0 {
        sql += fmt.Sprintf(" LIMIT %d", q.limit)
    }
    return sql, q.args, nil
}

// Usage
sql, args, err := NewQuery("users").
    Select("id", "name", "email").
    Where("active = $1", true).
    Where("role = $2", "admin").
    OrderBy("name").
    Limit(25).
    Build()

12. Strategy via Interfaces

Swap algorithms at runtime by accepting an interface. The caller chooses the strategy; the function does not need to know the implementation.

// Define the strategy interface
type Hasher interface {
    Hash(data []byte) []byte
    Name() string
}

// Multiple implementations
type SHA256Hasher struct{}

func (SHA256Hasher) Hash(data []byte) []byte {
    h := sha256.Sum256(data)
    return h[:]
}
func (SHA256Hasher) Name() string { return "sha256" }

type Blake2Hasher struct{}

func (Blake2Hasher) Hash(data []byte) []byte {
    h := blake2b.Sum256(data)
    return h[:]
}
func (Blake2Hasher) Name() string { return "blake2b" }

// Consumer accepts the interface - does not care about the algorithm
type FileStore struct {
    hasher Hasher
}

func NewFileStore(h Hasher) *FileStore {
    return &FileStore{hasher: h}
}

func (fs *FileStore) Store(path string, data []byte) error {
    checksum := fs.hasher.Hash(data)
    // write data and checksum to path
    return writeWithChecksum(path, data, checksum, fs.hasher.Name())
}

// Swap strategies at call site
fastStore := NewFileStore(Blake2Hasher{})
secureStore := NewFileStore(SHA256Hasher{})

This pattern is the Go equivalent of the Gang of Four Strategy pattern. It composes without inheritance and is trivially testable: inject a mockHasher that returns fixed bytes.