DEV Community

Cover image for We Killed `interface{}` From a Go Codebase. Here's What Replaced It
Gabriel Anhaia
Gabriel Anhaia

Posted on

We Killed `interface{}` From a Go Codebase. Here's What Replaced It


A team I worked with last quarter ran a one-line audit on their
backend repo:

git grep -nE 'interface\{\}|\bany\b' -- '*.go' | wc -l
Enter fullscreen mode Exit fullscreen mode

The number came back at 213. A backend that had grown from a Go
1.16 codebase, through the 1.18 generics release, into the
any alias era, with nobody ever going back to clean up. Some
of those 213 hits were honest: JSON unmarshal targets,
fmt.Sprintf arguments, a logger that took variadic context
fields. Most were not.

You probably have the same audit waiting in your repo. The
team grouped the 213 hits into three buckets and replaced almost
all of them with code that the compiler can actually check.
Below: three before/after refactors with line counts, and the
21 cases where any is still the right answer.

Bucket 1: collection containers that should be generic

The single biggest category was containers and helpers built
before generics existed. The shape was always the same. A slice
or a map of interface{}, plus a runtime type assertion at every
read site. The team counted 87 hits in this bucket.

The typical "before" was a small in-memory cache used by
the pricing service:

type Cache struct {
    mu    sync.RWMutex
    items map[string]interface{}
}

func (c *Cache) Get(key string) (interface{}, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    v, ok := c.items[key]
    return v, ok
}

func (c *Cache) Set(key string, val interface{}) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[key] = val
}
Enter fullscreen mode Exit fullscreen mode

Eighteen lines of cache. Every caller followed the same ritual:
get the value, type-assert, panic on the wrong shape in tests,
ship it, hope.

raw, ok := priceCache.Get(productID)
if !ok { return Price{}, ErrMiss }
p, ok := raw.(Price)        // every. single. read.
if !ok { return Price{}, ErrTypeMismatch }
return p, nil
Enter fullscreen mode Exit fullscreen mode

The "after" is one type parameter and zero assertions:

type Cache[T any] struct {
    mu    sync.RWMutex
    items map[string]T
}

func (c *Cache[T]) Get(key string) (T, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    v, ok := c.items[key]
    return v, ok
}

func (c *Cache[T]) Set(key string, val T) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[key] = val
}
Enter fullscreen mode Exit fullscreen mode

Same shape, same line count. Caller becomes:

priceCache := NewCache[Price]()
// ...
p, ok := priceCache.Get(productID)
if !ok { return Price{}, ErrMiss }
return p, nil
Enter fullscreen mode Exit fullscreen mode

The compiler now refuses any Set that does not pass a Price.
The runtime assertion is gone. The ErrTypeMismatch branch
deletes itself across 14 call sites. Across the 87 hits in this
bucket, this single substitution removed 312 lines of
defensive type-assertion code and surfaced two latent bugs where
a *Price was being stored in what callers assumed was a
Price cache.

Bucket 2: fake polymorphism over 2-3 known types

The second bucket was 64 hits where interface{} was being
used as a sum type. The shape: a function takes "any
notification payload" or "any audit event," then a type switch
inside the function dispatches to the real handler.

func Audit(ctx context.Context, evt interface{}) error {
    switch e := evt.(type) {
    case OrderPlaced:
        return writeOrderRow(ctx, e)
    case OrderCancelled:
        return writeCancelRow(ctx, e)
    case RefundIssued:
        return writeRefundRow(ctx, e)
    default:
        return fmt.Errorf("unknown event %T", evt)
    }
}
Enter fullscreen mode Exit fullscreen mode

The function signature lies. It says "any" but accepts exactly
three types. The compiler cannot help the caller pick a valid
shape. The default branch is a runtime trap that exists
because the type system was bypassed.

The team replaced this with a small consumer-side interface,
defined in the package that uses it (the audit package), not in
the package that declares the events:

type AuditEvent interface {
    auditRow() row
}

func (e OrderPlaced)    auditRow() row { /* ... */ }
func (e OrderCancelled) auditRow() row { /* ... */ }
func (e RefundIssued)   auditRow() row { /* ... */ }

func Audit(ctx context.Context, evt AuditEvent) error {
    return write(ctx, evt.auditRow())
}
Enter fullscreen mode Exit fullscreen mode

A few things changed in that shape:

  1. The interface is unexported (auditRow, lowercase). Only types in this package can implement it. The set of accepted events is closed at compile time. Adding a fourth event type is a deliberate edit in the audit package, not a silent runtime "unknown event" log line.
  2. The interface is defined where it is consumed, not where the events are declared. The domain package does not depend on the audit package. The audit package writes its own shape, asks domain types to satisfy it.
  3. The function signature is now a contract. A caller that tries to pass Login{} gets a compile error, not a 500 with a log line.

This refactor cost about 30 lines of method declarations across
the codebase and deleted 47 lines of type-switch / default-trap
code. The line count dropped, the type safety went up.

The same shape worked for "any payment method," "any feature
flag value source," and "any retry decision." If the type
switch has fewer than five arms and the set is closed at
compile time, the answer is a small named interface.

Bucket 3: reflection-y serialization

The third bucket was 41 hits where interface{} was being
used to dodge the encoding layer. Internal events flowing
through Kafka were marshalled as map[string]interface{} and
fished out with reflection on the consumer side.

func handle(ctx context.Context, raw map[string]interface{}) {
    typ, _ := raw["type"].(string)
    payload, _ := raw["payload"].(map[string]interface{})

    switch typ {
    case "user.created":
        id, _ := payload["id"].(string)
        email, _ := payload["email"].(string)
        // ... five more fields ...
        createUser(ctx, id, email /* ... */)
    case "order.placed":
        // ... fifteen more lines of payload[...].(string) ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Every field a runtime cast. Every typo a silent zero value.
The producer and the consumer agreed on the wire format only by
convention.

The team replaced this with a typed envelope and explicit
unmarshalling per case:

type Envelope struct {
    Type    string          `json:"type"`
    Payload json.RawMessage `json:"payload"`
}

type UserCreated struct {
    ID    string `json:"id"`
    Email string `json:"email"`
}

type OrderPlaced struct {
    OrderID string  `json:"order_id"`
    Total   float64 `json:"total"`
}

func handle(ctx context.Context, b []byte) error {
    var env Envelope
    if err := json.Unmarshal(b, &env); err != nil {
        return err
    }
    switch env.Type {
    case "user.created":
        var p UserCreated
        if err := json.Unmarshal(env.Payload, &p); err != nil {
            return err
        }
        return createUser(ctx, p)
    case "order.placed":
        var p OrderPlaced
        if err := json.Unmarshal(env.Payload, &p); err != nil {
            return err
        }
        return placeOrder(ctx, p)
    }
    return fmt.Errorf("unknown event %s", env.Type)
}
Enter fullscreen mode Exit fullscreen mode

json.RawMessage is the unsung hero here. It defers
unmarshalling of the inner payload until you know the type,
without any reflection on your side. Each case branch
unmarshals into a real struct with real json tags. A
mistyped field name fails at unmarshalling time with a clear
error, not silently as a zero value 200 lines downstream.

This refactor was the most expensive one. About 180 lines of
struct definitions added, 240 lines of payload[...].(string)
deleted, plus a one-time compatibility layer to read both old
and new envelope shapes during the migration window. The line
count moved a little. The class of bug that used to fire at
deserialization time became impossible, which was the actual
win.

Where any is still the right answer

Of the 213 original hits, 21 stayed. The team kept any in
exactly two shapes:

Genuinely heterogeneous trees. A function that walks a
parsed AST or a generic JSON document, where the node value
really can be string, number, bool, array, or object. Forcing
this through a sum-type interface adds five method
declarations per concrete type and a visitor pattern that
nobody wants to read. any plus a type switch at the leaves
is the right answer.

func walk(v any, fn func(string, any)) {
    switch x := v.(type) {
    case map[string]any:
        for k, vv := range x { fn(k, vv); walk(vv, fn) }
    case []any:
        for _, vv := range x { walk(vv, fn) }
    }
}
Enter fullscreen mode Exit fullscreen mode

Structurally typed unmarshal targets. When you cannot
control the shape of incoming JSON (a third-party webhook
that adds fields you do not care about, a config blob you pass
through to a downstream system), map[string]any as the
target is honest. You are not modelling a domain; you are
ferrying bytes.

The rule the team landed on: when the type is genuinely unknown
to your code, any is fine. When you know the type and just
have not told the compiler yet, it is the wrong answer.

The audit, repeated

The same one-liner six months later returned 24 hits: the 21
keepers above plus three new ones a junior added during a
sprint. Code review caught those three the same week. The
codebase had crossed the line where "see an any, raise an
eyebrow" was the default reaction instead of "another one,
shrug."

Your repo probably has its own 213. Run the audit. Sort the
hits into the three buckets. The first two buckets (generic
containers and fake polymorphism) pay back in days. The third
takes a sprint, and the bug class it removes is the one that
fires at 3 a.m. on a payload variation nobody wrote a test
for.


If this was useful

Most of the refactors above are about putting the type system
where the runtime check used to be: small interfaces defined
on the consumer side, typed structs at the boundary, generics
where the container did not need to know what it was carrying.
Hexagonal Architecture in Go covers exactly this discipline:
which package owns which interface, where the boundary
unmarshals into a domain type, and how to keep the inner
hexagon free of any. The Complete Guide to Go Programming
goes deeper on generics, type sets, and the runtime cost of
the patterns above.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)