Modern Interface Features (Generics Era)
How Go interfaces evolved with generics, enabling type sets, basic and general interfaces, and the comparable constraint
Go 1.18 brought generics, and with them the role of interfaces expanded dramatically. Before generics, an interface was purely a collection of method signatures—any type with those methods satisfied it. After generics, an interface can also define a set of types directly. This turns interfaces into the mechanism for expressing constraints on type parameters: what types a generic function or type can accept, and what operations are safe to perform on them. The shift is so fundamental that the language now distinguishes between two categories of interfaces, and it introduces a new predeclared constraint—comparable—that only works inside type parameter lists.
Interfaces as Type Sets
Traditionally, you thought of an interface as a method set: the methods a type must have to be assigned to that interface variable. With generics, it is more powerful to think of an interface as a type set—the set of all types that satisfy it. An interface that lists only methods defines the set of types that implement those methods. The new syntax lets you add types explicitly to that set, with union elements and underlying‑type approximations.
A type set is built by combining one or more of these inside an interface:
- Method signatures (like
Read([]byte) (int, error)) - Type terms: a concrete type, or a tilde‑prefixed underlying type, or a union of both separated by
|
For example, the constraints.Ordered interface (now cmp.Ordered in the standard library) specifies all types that support the < operator:
type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64 |
~string
}
The ~T notation means “all types whose underlying type is T.” It covers not just int itself but any named type based on int, like type MyInt int. Without the tilde, the union int | float64 would reject a user‑defined type Score int. With ~int, Score is allowed.
When used as a type constraint, the interface declares: the type argument must belong to this type set. Inside the generic function or type, every operation on a value of that type parameter must be valid for all types in the set. Because every type in Ordered supports <, you can write:
func Max[T Ordered](a, b T) T {
if a > b {
return a
}
return b
}
Why the tilde matters:
Many standard library types are defined with named types derived from basic ones, e.g., time.Duration is type Duration int64. A constraint like int64 would reject Duration; ~int64 accepts it. Use the tilde when you want to work with any type that has the same underlying representation, not just the exact predeclared type.
A constraint can mix methods and type terms. Suppose you need a numeric type that also knows how to format itself:
type StringableNumber interface {
~float32 | ~float64
String() string
}
Any type used as a type argument must be in the union and have a String() string method. The set is the intersection of the type terms and the method‑satisfying types.
All elements must be satisfied simultaneously:
A common mistake is to assume that a union like int | string with a method means “either int or string that happen to have the method.” The constraint actually requires that the single type argument belongs to the union and implements the method. You cannot pass int alone unless int itself has that method (which it won’t, for user‑defined methods). Use such constraints only with types that you control or that naturally meet all requirements.
The type‑set view underpins everything else in this chapter. It is the mental model that explains why some interfaces can appear as variable types and others cannot.
Basic vs General Interfaces
The Go spec draws a line between basic interfaces and general interfaces. Understanding the difference prevents compile‑time confusion and tells you where an interface can legally be used.
A basic interface is any interface whose type set is defined entirely by methods. This includes:
- Traditional method‑only interfaces:
interface{ Read([]byte) (int, error) } - Empty interface:
interface{}(nowany) - Embedded interfaces that themselves are basic
- Generic interfaces that, once instantiated, become method‑only, e.g.,
Comparer[E any] interface{ Compare(E) int }— the typeComparer[string]is a basic interface.
A general interface contains at least one type element that is not a method: a concrete type, a ~T term, or a union. Even a single union element makes the interface general.
type IntOrString interface {
int | string
}
The practical rule: basic interfaces can be used anywhere an interface is allowed—as a variable type, function parameter, map key, or in a type assertion. General interfaces can only be used as type constraints (in type parameter lists) or embedded inside other constraints.
General interfaces cannot hold values:
Attempting to declare a variable of a general interface type results in a compiler error:
var x IntOrString // ERROR: interface contains type constraints
The language explicitly forbids this because int | string does not define a meaningful value type—there is no common method set for operations, only a constraint.
This restriction is not arbitrary. A variable of type IntOrString would have a dynamic value that could be an int or a string, but you could not call any method on it except the universal ones (like comparison with == if the type set happens to be comparable), and type‑switching would be required for any useful work. The design choice keeps interfaces predictable: if you see a variable of interface type, you know it’s a basic interface and you can use it as a normal value.
Generic interfaces like Comparer[E any] introduce a nuance. The generic interface itself is a general interface because its definition uses a type parameter as a constraint. But Comparer[string] is a basic interface—once the type parameter is resolved, only a method remains. So you can write:
var c Comparer[string] = time.Time{} // ok: Comparer[string] is basic
but you cannot assign to Comparer without instantiation.
Check your instantiation:
If you get a “type constraint” error on a variable declaration and the interface has type parameters, make sure you instantiate it with a concrete type argument. Only the instantiated form may become a basic interface.
The basic/general split also answers a common question: why some interfaces can be used as map keys and others cannot. A map key type must be comparable, and to be comparable an interface type must be basic and its type set must consist of comparable types. General interfaces are not themselves comparable.
For most day‑to‑day generic code, you will write general interfaces as constraints and basic interfaces as value types. The distinction is a safety net that keeps generic constraints from leaking into ordinary value contexts where they would add confusion.
The comparable Constraint
comparable is a predeclared constraint, like any, but much narrower. It is satisfied only by types whose values can be compared with == and !=. It was introduced specifically for generic programming—you cannot use comparable as the type of a variable.
The set of comparable types includes:
- Booleans, numbers, strings
- Pointers
- Channels
- Interfaces (basic interfaces only, but remember that interface values are comparable—they may panic at runtime if the dynamic type is not comparable, though the constraint itself doesn’t check for that)
- Structs and arrays whose every field/element is comparable
Slices, maps, and functions are not comparable.
The most frequent use of comparable is to allow a type parameter to act as a map key, or to compare elements in a slice for equality:
func Contains[T comparable](s []T, e T) bool {
for _, v := range s {
if v == e {
return true
}
}
return false
}
Without comparable, the compiler would reject the v == e comparison because not all types support ==. With the constraint, the function works for int, string, and any user‑defined comparable struct.
comparable vs interface values at runtime:
A type that satisfies comparable passes the constraint at compile time even if its dynamic values might panic on ==. For instance, an interface type is comparable, so comparable accepts it. But if you store a slice inside that interface and later compare, the program panics. The constraint only ensures that == is syntactically permitted; it does not guarantee safe runtime comparisons. Always be careful when comparing interface values in generic code.
You can embed comparable inside a custom interface to create a constraint that requires both comparability and specific methods. This is common for generic data structures like ordered sets that need both a map‑backed lookup and sorted traversal:
type Comparer[E any] interface {
Compare(E) int
}
// ComparableComparer requires the type to be comparable AND have a Compare method.
type ComparableComparer[E any] interface {
comparable
Comparer[E]
}
type OrderedSet[E ComparableComparer[E]] struct {
index map[E]int
// ...
}
Here comparable guarantees E can be used as a map key, while Comparer[E] provides the ordering logic.
When comparable clicks:
If your generic function compiles with [T comparable] and you call it with a custom struct, the compiler has verified that struct’s comparability. You can confidently use it as a map key or with == inside that function. The constraint acts as a compile‑time guard that catches non‑comparable types early.
One common pitfall is assuming comparable is a replacement for any or that it is automatically satisfied by pointer types to structs. A pointer to a struct is comparable, but the struct itself might not be—the constraint applies to the type argument you provide, not to some related type. Pass *MyStruct, not MyStruct, if the struct contains incomparable fields.
comparable is a small keyword with a large impact: it bridges the gap between generic algorithms and Go’s equality semantics, making maps and comparisons safe inside type‑parameterized code.
Summary
The generics era redefines Go interfaces as type sets, not just method lists. This unlocks three interrelated features. Interfaces as type sets let you spell out exactly which types a generic function accepts, with union and tilde syntax for precision. Basic vs general interfaces prevent you from accidentally using a constraint as a concrete value, keeping the language’s value semantics clean. The comparable constraint closes the loop, giving you a way to demand comparability when you need maps or equality checks inside generic code.
The thread that ties them together is the type‑parameter list: every constraint you write is an interface, and whether that interface is general or basic, method‑only or type‑enriched, determines where and how it can be used. When you design a generic API, start by asking what operations you need on the type parameter, then craft an interface that captures exactly that type set.