Interfaces as Type Sets
How Go interfaces expanded beyond method sets to describe exact sets of types for generic constraints
Before Go 1.18, an interface meant one thing: a set of methods. A type satisfied the interface if it had all those methods. The set of types that could be stored in an interface variable was the set of all types that implemented those methods — a set you could never write down directly, because it was inferred from method signatures.
Generics needed something different. A generic function like func Min[T ...](a, b T) T needs to know what types T can be, not just what methods T must have. The old “method set” view couldn't express “T must be an integer” or “T must support <”. It could only require methods like Less(T) bool.
Go 1.18 extended the interface language to let you enumerate types directly inside an interface definition. These are type elements, and the interface now defines a type set — the exact collection of types that satisfy the constraint.
A Shift in Perspective:
Even the old method-only interfaces are now described as type sets. The interface interface{ String() string } defines the set of all types that have a String() string method. The new syntax just lets you add more explicit membership rules to that set.
Why the Old Model Wasn't Enough for Generics
Consider a function that returns the smaller of two values. Without generics, you'd write separate functions for float64 and int, or rely on reflection. Generics solve that:
func Min[T any](a, b T) T {
if a < b {
return a
}
return b
}
The any constraint says T can be any type. But the body uses <. Not every type supports that operator, so the compiler rejects the code: invalid operation: cannot compare a < b (operator < not defined for T).
To fix it, you need a constraint that tells the compiler “T is a type where < works.” That constraint isn't a method — there is no Less method on int. It has to be expressed as a set of types: all integer types, all float types, and all string types. Interfaces as type sets make that possible.
How Type Elements Define a Set
An interface can now include type elements inside its body. The two primary forms are union elements and approximation elements.
Union elements with |
A union lists specific types that belong to the set:
type Integer interface {
int | int8 | int16 | int32 | int64
}
This interface is satisfied by exactly those five concrete types. You can now use it as a constraint:
func Add[T Integer](a, b T) T {
return a + b
}
fmt.Println(Add[int16](3, 4)) // 7
Union elements support any non-interface type, including user-defined types.
type MyInt int
type SpecialSet interface {
int | MyInt
}
Now SpecialSet accepts int and MyInt — but not int64 or other aliases.
Union is Not Embedding:
Do not confuse the vertical bar | with interface embedding. A | B means the set of types that are either A or B. Embedding A; B (or multiple lines) means the set of types that satisfy both A and B — an intersection. The syntax for intersection is not a vertical bar; it's the usual embedded interface syntax.
Approximation element ~T
A union of every possible integer type would be large and brittle. What if a user defines type ID int? The int in the union doesn't cover it because ID and int are distinct types in Go, even though they share the same underlying representation.
The approximation token ~ means “the set of all types whose underlying type is T.” So ~int matches int and any named type derived from int.
type Integer interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
Now type ID int satisfies the constraint, and Add[ID](... works without explicit conversion.
Approximation elements can also apply to string types, slice types, and so on:
type Stringish interface {
~string
}
func Concat[T Stringish](a, b T) T {
return a + b
}
type Name string
fmt.Println(Concat(Name("Hello "), Name("World")))
Practicality:
Using ~ lets library authors write generic functions that work with all common numeric types — including the dozens of named integer and float types in typical codebases — without maintaining an exhaustive list.
Combining Methods and Type Elements
The real power emerges when you mix method requirements and type elements within the same interface. The type set is then the intersection: types that both belong to the type element set and implement the listed methods.
type ComparableStringer interface {
~string
String() string
}
type Label string
func (l Label) String() string {
return "Label: " + string(l)
}
func PrintIf[T ComparableStringer](v T) {
fmt.Println(v.String())
}
Label has underlying type string and a String() string method, so it satisfies ComparableStringer. A plain string does not, because it has no String method.
You can embed comparable or other interfaces alongside type elements to further restrict the set.
type ComparableInt interface {
comparable
~int | ~int64
}
This matches any type comparable with == whose underlying type is int or int64.
Interfaces That Cannot Be Used as Values
Before generics, every interface could be used as the type of a variable:
var w io.Writer = os.Stdout
An interface with type elements (like Integer above) is called a general interface in the spec and cannot appear in a variable declaration. The compiler rejects it:
var x Integer = 5 // compile error: interface contains type constraints
General interfaces exist only to constrain type parameters. If you need a runtime value, you must use a basic interface — one that has only methods (or the empty interface any).
Compile-Time Error:
Attempting to use an interface with a type element as a variable type, function parameter type, or field type will fail at compile time. The error message will state that the interface contains type constraints and is not a valid type for the context.
This restriction is deliberate. If a variable could hold an Integer interface, the language would have to support operations like + on the interface value, which would require a complex dispatch mechanism. Confining type-set interfaces to constraints keeps the runtime simple and the generic code monomorphised.
comparable and Predeclared Type Sets
Go provides one built-in type set through the predeclared interface comparable. It is satisfied by any type whose values can be compared with == and !=. This includes all non-interface types except slices, maps, and functions, plus interface types that have comparable dynamic types.
func Contains[T comparable](s []T, e T) bool {
for _, v := range s {
if v == e {
return true
}
}
return false
}
Unlike Integer, the comparable interface can be used as a constraint and as a variable type in limited contexts (e.g., as a map key type in a generic type definition). Its internals are defined by the language spec, not by type elements you can write yourself.
comparable as a Type Set:
The Go specification defines comparable as the set of all comparable types. You cannot recreate it with union or approximation elements, because that set is infinite and includes all struct and array types whose fields are comparable.
The cmp.Ordered Interface in the Standard Library
Since Go 1.21, the cmp package exports the Ordered interface, which is defined entirely through type elements:
type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64 |
~string
}
It is the canonical constraint for functions that rely on <, <=, >, and >=. The standard library uses it in slices.Sort, maps.Keys, and cmp.Compare. If you write generic code that needs ordering, cmp.Ordered is the right constraint.
import "cmp"
func Max[T cmp.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
Common Misconceptions
“An interface with type elements can have methods and still be used as a variable.”
No. As soon as a type element appears in an interface, the whole interface becomes a constraint-only interface, even if it also lists methods. The only exception is comparable, which is a special predeclared interface that does not follow the usual rule about type elements (it can appear in map key types).
“~int means any type assignable to int.”
No. Assignability and underlying type are different. type Celsius float64 cannot be assigned to float64 without conversion, but ~float64 covers Celsius because its underlying type is float64. Approximation follows underlying types, not assignability.
“Unions of method-only interfaces work like unions of types.”
You cannot write interface{ io.Reader | io.Writer }. Type elements only permit non-interface types or approximation literals. You can embed interfaces to form intersections (the usual way), but there is no syntax for union of interfaces.
“Type sets replace method sets completely.”
They coexist. Most day-to-day interfaces (like io.Reader) are still purely method-based and will remain so. Type sets are a tool for generic constraints, not a replacement for behavioural abstraction.
Summary
Interfaces as type sets is the language extension that made Go generics possible. By allowing interfaces to list exact types — through union, approximation, and intersection — Go gained a precise way to constrain type parameters without sacrificing compile-time safety. The type set of an interface is the set of all types that satisfy both its method requirements and its type elements.
Key takeaways:
- Use
|to create a union of allowed types. - Use
~Tto accept all types with underlying type T. - An interface with any type element cannot be used as a runtime value.
cmp.Orderedis the standard type-set constraint for ordered types.
If you understand that a constraint is just a named type set, you can design generic APIs that are safe, expressive, and work with both built-in and user-defined types.