- Book: Hexagonal Architecture in Go
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You wrote a generic. You picked a type parameter T. You
wrote [T any] because the linter stopped complaining and the
function compiled. Then you boxed a value into any halfway
down the body to keep a helper "flexible." Then you returned
any from a method on a generic struct so callers could
"decide later." The signature has square brackets. The body
still ferries interface{} around like it's 2021.
This is the second wave of any in Go codebases. The first
wave was containers and helpers from before generics existed.
That one is a known refactor: sweep through, add a type
parameter, delete the type assertions. The second wave is more
embarrassing because it appears in code that was written
after generics shipped. The tool was there. It got used
halfway.
Here are three patterns where any hides inside generic code,
and the move that fixes each one.
Pattern 1: the generic that promises "any value" and means it
The first shape is a generic that takes [T any] and never
asks T to do anything. Picking any is the cheapest way to
get a function to compile when you have no idea what you want
from the type parameter.
The clue is a body that only stores, returns, or logs T.
It never compares T, never indexes into it, and never calls
a method on it. The function is parametrically polymorphic in
name only.
type Set[T any] struct {
items map[T]struct{}
}
func (s *Set[T]) Add(v T) { s.items[v] = struct{}{} }
func (s *Set[T]) Has(v T) bool { _, ok := s.items[v]; return ok }
This will not compile, and that is the right kind of failure.
map[T]struct{} requires T to satisfy comparable, so the
build breaks on the type definition itself. The lesson is not
that any lets bad code slip past — it is that any is a
signal you skipped the step where you state what T actually
needs to do. The compiler is telling you to write the
constraint.
The right constraint is the one your code actually uses. If
the body keys a map by T, the constraint is comparable.
Sorting T? You need cmp.Ordered. Adding T values? A
numeric type set with tildes.
type Set[T comparable] struct {
items map[T]struct{}
}
func (s *Set[T]) Add(v T) { s.items[v] = struct{}{} }
func (s *Set[T]) Has(v T) bool { _, ok := s.items[v]; return ok }
Now the compiler refuses Set[Order] if Order contains a
slice or a map. The error fires at the type-instantiation
site, with a message that names the constraint you violated,
instead of the cryptic build error you got from the bare
map[T]struct{} line.
A useful working rule. Read the body of every generic in your
repo. If the body uses ==, the constraint is at least
comparable. If it uses <, the constraint is at least
cmp.Ordered. If it uses neither, ask why the function is
generic at all. Sometimes the answer is "because I want a
named container," which is fine. Often the answer is "I did
not think about it," which is the bug.
Pattern 2: the generic helper that boxes through any for "flexibility"
The second shape is uglier. A generic function exists, the
type parameter is sound, and then somewhere in the middle the
author calls a helper that takes any. The type information
is laundered through interface{}, lost, and re-asserted on
the other side.
type Result[T any] struct {
Value T
Err error
}
func Wrap[T any](fn func() (T, error)) Result[T] {
v, err := fn()
return Result[T]{Value: v, Err: err}
}
func logResult(r any) {
switch x := r.(type) {
case Result[int]:
fmt.Println("int result:", x.Value, x.Err)
case Result[string]:
fmt.Println("string result:", x.Value, x.Err)
default:
fmt.Printf("unknown result %T\n", r)
}
}
The logResult helper takes any and fans out on a type
switch that lists the three or four Result[T] shapes the
caller cared about. Adding a fifth means editing the switch
and remembering to also update the call site. Forgetting the
edit means the default arm logs unknown result Result[Order]
in production at 2 a.m.
The fix is to add the operation you actually want to the
constraint, not to launder values through any.
type Result[T fmt.Stringer] struct {
Value T
Err error
}
func (r Result[T]) Log() {
if r.Err != nil {
fmt.Println("error:", r.Err)
return
}
fmt.Println("ok:", r.Value.String())
}
If T needs to print itself, ask for fmt.Stringer. Need
ordering? Reach for cmp.Ordered. Need both ordering and a
method? Write a small constraint that combines a type-set
element and a method element, and accept that plain int no
longer fits because it does not implement String(). That is
the contract.
The mistake is treating any as a bridge between two pieces
of generic code. An any parameter is not a bridge between
two generic call sites; it is a hole. Values that cross it
lose their type, and the receiving side has to guess. The Go
type system has a perfectly good way to say "I need this
operation on T": put the operation in the constraint.
When the operation lives on the value but not on the type
parameter, you have an even cleaner option. Skip the
parameter entirely. Write a non-generic interface, take
that interface as the argument. A function that calls
.Log() on its argument should not be generic. It should
take a Logger.
Pattern 3: the pre-1.18 interface{} that nobody migrated
The third shape is the easy one to catch and the one most
codebases still ship. A type was written before generics. The
go.mod was bumped to 1.21 three years ago. The type still
has interface{} in it because nobody opened the file.
type EventBus struct {
handlers map[string][]func(interface{})
}
func (b *EventBus) On(topic string, fn func(interface{})) {
b.handlers[topic] = append(b.handlers[topic], fn)
}
func (b *EventBus) Emit(topic string, evt interface{}) {
for _, fn := range b.handlers[topic] {
fn(evt)
}
}
Every handler asserts on the way in:
bus.On("order.placed", func(e interface{}) {
o, ok := e.(OrderPlaced)
if !ok {
log.Println("bad event")
return
}
handle(o)
})
Every emit relies on the caller passing the right shape:
bus.Emit("order.placed", OrderPlaced{ID: id})
Generics were available the day this codebase was bumped past
1.18. Nobody went back. The interface{} survived through the
any alias rename in 1.18, through the standard library
maps/slices packages in 1.21, through cmp.Ordered and
min/max builtins. Nobody touched it.
The migration is mechanical: rename interface{} to E, lift
the parameter to the type, and let the call sites update by
type inference. One bus per event type, or one generic bus
parameterised on the event:
type Bus[E any] struct {
handlers []func(E)
}
func (b *Bus[E]) On(fn func(E)) {
b.handlers = append(b.handlers, fn)
}
func (b *Bus[E]) Emit(evt E) {
for _, fn := range b.handlers {
fn(evt)
}
}
orderBus := &Bus[OrderPlaced]{}
orderBus.On(func(o OrderPlaced) { handle(o) })
orderBus.Emit(OrderPlaced{ID: id})
The handler signature drops the type assertion. The emit
signature refuses anything that is not an OrderPlaced. The
"bad event" log line stops being possible. If you genuinely
need a single bus that carries many event types, you build a
sum type with a small consumer-side interface. You do not
fall back to interface{} and a runtime switch.
The reason this pattern survives is that nobody is shamed by
their own old code. The fix is to add a CI check that fails
the build when interface{} shows up in a non-vendored file.
any is fine; interface{} in 2026 is dead weight. The
spelling difference matters. interface{} in a diff in 2026
means somebody copy-pasted from a Stack Overflow answer
written in 2019. That answer should be rewritten.
Where any is fine, even in generic code
The same exceptions hold inside generics that hold outside.
Walking a parsed JSON tree where the leaves can be string,
number, bool, array, or object. Holding values you intend to
hand to fmt.Sprintf or json.Marshal. Returning from
json.Unmarshal into a map[string]any because the wire
shape really is open.
The test is whether you, the author, know the type. If you
know it and the compiler does not, the constraint is wrong.
If you genuinely do not know it, any is honest. The
distinction is not pedantic. When any means "I don't know,"
the function is doing its job. When it means "I didn't want to
write the constraint," it's a runtime trap.
The audit
The audit is a one-liner, scoped to your generic packages:
git grep -nE '\[[A-Z][A-Za-z0-9]* (any|interface\{\})\]' \
-- '*.go'
That regex targets type-parameter syntax — [T any] and the
interface{} spelling — instead of every line that happens to
use brackets. Expect a quick eyeball pass on the hits, then
read each one and answer the same three questions:
- What operation does the body of this generic do on
T? - Is the constraint the smallest one that admits that operation?
- Is
anyappearing because I do not know the type, or because I did not want to write the constraint?
The third answer is the one to act on. The other two are
diagnostic.
If this was useful
Most of the second-wave any shows up where the constraint
should have been narrower from the start, or where a generic
helper was supposed to take a constrained T and instead got
a tour through interface{} for "flexibility." Hexagonal
Architecture in Go puts a hard line between the boundary
layer (where unknown types do exist) and the inner hexagon
(where they should not), and that line is what keeps the
escape hatch from sneaking back in. The Complete Guide to Go
Programming covers the constraint vocabulary — comparable,
cmp.Ordered, the tilde, custom type sets — in enough depth
that you can write the constraint you actually need instead
of falling back to any.

Top comments (0)