Method-based constraints can only describe what a type does, not what it is. That's a problem the moment you want a generic function that adds numbers, compares with <, or uses any other operator. Type sets fix this by letting a constraint list concrete types directly, with two extra tools: the union operator | for "any of these" and the approximation prefix ~ for "any type whose underlying type is this." This chapter covers how type sets are spelled, what operators they unlock, how to mix them with method requirements, and how to package the common ones as reusable named constraints.
Method-based constraints, the familiar interface { Method() } style, work fine for any operation expressed as a method call. The trouble starts with operators.
Suppose you want a generic Sum that adds up a slice of numbers. A method constraint can't help you. There's no Add method on int or float64; addition is the built-in + operator. The compiler needs to know that the type parameter supports + at compile time, and the only way to tell it is by listing the types that do.
Here's the attempt that fails:
This doesn't compile because int has no Add method. You could wrap every numeric type in a struct, define Add on each wrapper, and pay the boxing cost on every call. Nobody writes Go that way. The point of Sum over int is that the compiler already knows how to add two int values.
What we want is a constraint that says "T must be one of the built-in numeric types." That's a type set.
The constraint Number says T is one of int, int64, or float64. Because every type in that set supports +, the compiler accepts total += v. The same body works for both []int and []float64, picked at the call site by type inference.
Type sets are how generics become useful for everyday math, comparisons, and string concatenation. Without them, you'd be stuck writing one function per numeric type, exactly the problem generics exist to solve.
The element inside an interface that names a concrete type, rather than a method, is called a type element. Until Go 1.18 it wasn't even legal to write one; interfaces were strictly about methods. Generics changed that.
The simplest type element is a single type name:
OnlyInt is an interface, but its type set contains exactly one type: int. You can use it as a constraint:
A one-type constraint like this is rare in practice. It's almost the same as just writing func DoubleQuantity(q int) int without generics at all. But it shows the mechanic: a type element narrows the set of allowed type arguments to the listed types.
A subtle point: an interface used as a constraint has two roles inside it. Method elements (the old-style String() string lines) and type elements (the new-style concrete type lines). The full type set is the intersection of "types that satisfy every listed method" and "types in the type-element list." We'll see both together later in the chapter.
Every constraint describes a type set, a subset of all Go types. When you write a generic function with constraint C, a call is legal only if the type argument is in C's type set. A method-only constraint defines its type set as "everything that has these methods." A type-element constraint defines it by direct enumeration. A constraint that mixes both takes the intersection.
The grammar inside an interface is unchanged from earlier Go for the method side. What's new is that you can also write lines like int, []byte, *Order, and so on, each of which contributes types to the set.
|A single-type element is rarely what you want. Most useful constraints cover several types at once, and that's where the union operator | comes in.
The type set of IntOrFloat is exactly two types: int and float64. You can chain as many alternatives as you need:
Read the bar as "or". A type argument satisfies the constraint if it's any one of the listed types. There's no upper bound on how many you can list, though more than ten starts to feel cluttered, and that's where named constraints (later in this chapter) come in.
Here's a Max function that uses a union to allow any of three numeric types:
The body of Max uses >, which is legal for every type in the union. If you removed float64 and tried to call Max([]string{...}), the compiler would reject it because string isn't in the type set, even though > would technically work on strings.
A union by itself doesn't give you operators that some members support and others don't. The rule is strict: an operator is allowed inside the generic function only if every type in the type set supports it. Add complex128 to that union and > stops working, because complex numbers don't support ordering.
The compiler reports:
It's the type set, not any single member, that determines what's legal. The compiler requires every type in the union to support an operator before allowing it in the generic body. If even one member doesn't support <, none of them do as far as the generic body is concerned.
~There's a problem hiding in the constraint int | int64 | float64. It accepts the literal types int, int64, and float64, and only those types. The moment a project defines its own named type on top of one of them, the constraint stops working.
This bites e-commerce code immediately. Money is almost never stored as a raw int64. It's stored as a named type so you can't accidentally pass a quantity where you meant a price:
Now look at what happens with the obvious constraint:
The compiler rejects this:
Cents has the same underlying type as int64, but it's a distinct named type. The type set int | int64 | float64 doesn't contain Cents. The fix is the ~ prefix, which says "include this type and every type whose underlying type is this":
Read ~int64 as "any type with underlying type int64." That includes int64 itself, Cents, Quantity (if it were int64-based), and anything else built on int64. Here's the corrected version:
Now Cents flows through Sum without conversion. The result keeps the Cents type, so the caller never sees a raw int64 and can't accidentally mix money with quantities later in the code.
The diagram shows the difference between int64 (one type) and ~int64 (a family of types). Every type whose underlying type is int64 is in the set: int64 itself, plus every user-defined alias on top of it. Types with a different underlying representation, like float64, are still excluded.
Cost: Using ~ doesn't add runtime overhead. The compiler picks the same code path it would have picked for the underlying type. The only "cost" is one extra character per element in the constraint.
A common question: should every numeric constraint use ~? Yes, in nearly all practical cases. The exception is when you want to forbid defined types deliberately, for example, an internal API that should only ever receive raw primitive types. That's rare. The default is ~int | ~int64 | ..., and the constraints in golang.org/x/exp/constraints (and Go 1.21+'s standard library equivalents) all use ~.
A second subtle point: ~ only makes sense in front of types that already have an underlying type. Interfaces, generic types, and a few other forms can't be the target of ~. For built-in numeric, boolean, and string types, it works exactly as you'd expect.
The whole reason to spell out concrete types is to gain access to the operators those types support. The compiler grants an operator inside a generic body only when every type in the set supports that operator. Knowing the mapping by heart saves a lot of constraint debugging.
| Constraint | Type set | Operators allowed in the body |
|---|---|---|
~int | ~int64 | All integer types named | +, -, *, /, %, <, <=, >, >=, ==, !=, <<, >>, &, |, ^, &^ |
~float32 | ~float64 | All float types named | +, -, *, /, <, <=, >, >=, ==, != |
~int | ~float64 | Mixed ints and floats | +, -, *, /, <, <=, >, >=, ==, != (no %, no bit ops) |
~string | All string types | +, <, <=, >, >=, ==, != |
~int | ~string | Integers and strings | +, <, <=, >, >=, ==, != (only operators both share) |
comparable | All types supporting == | ==, != only |
any | All types | None of the above |
A few patterns to keep in mind:
== (so structs of comparable fields, but not slices or maps).Here's a function that exercises the "ordered numeric" set with both integers and floats:
Min works for any orderable type. Notice that ~string is fine to mix in because < is defined on strings (lexicographic order). If we tried to add the * operator inside the body, the constraint would have to drop ~string because string multiplication isn't a thing in Go.
ApplyDiscount uses -, *, and /. All four types in Numeric support them, so the body compiles. The same function handles Cents (because ~int64 includes it) and float64 without any wrapper or conversion.
Cost: The compiler generates one version of the function body for each distinct underlying type used as a type argument (or shares an implementation when the layout matches, the exact rules are an implementation detail of the runtime's GC shape system). Don't be afraid of generics for performance. The generated code is the same as what a hand-written non-generic function would produce, in nearly all cases.
An interface can carry method requirements and type-set elements at the same time. This is rare in production code but legal, and it shows up in a few specific patterns.
The semantics: a type satisfies the interface only if it appears in the type-set elements and has all the listed methods. The two conditions are conjunctive (AND), not disjunctive (OR).
Here's a constraint that requires a numeric type that also has a String() string method, which means a custom display format:
Cents qualifies on both counts. Its underlying type is int64, so it's in the type-set portion. It has a String() string method, so it satisfies the method requirement. Inside Display, you can both add the values (because every type in the union supports +) and call .String() on them.
A raw int would not satisfy PrintableNumber. Even though int is in the type-set, it doesn't have a String() string method. Calling Display(42) fails with:
This is the conjunction in action. The constraint isn't "int OR has String"; it's "an integer/float type AND has String."
The diagram is a two-condition Venn intersection. The type set of a mixed constraint sits where both conditions overlap. A type in only one circle (int without String, or time.Duration if it weren't numeric) doesn't qualify.
When does mixing pay off? Mostly in serialization helpers and formatting code that wants to operate on a specific shape of numeric type with a custom presentation. Most generic functions need either operators or methods, not both. Forcing both narrows the constraint to almost nothing and rarely justifies its complexity.
One thing the mix gives you that pure type-set constraints don't: the ability to call methods inside the generic body. Without the method requirement, value.String() wouldn't compile, because nothing in ~int | ~int64 | ~float64 guarantees a String method exists.
By the third generic function you write, you'll notice you're typing the same type-set unions over and over. The solution is to name them once.
Three details stand out in that constraint declaration.
First, Numeric lists every integer width, signed and unsigned, plus both float widths. That's verbose but it's a one-time cost; every generic function using Numeric benefits.
Second, Ordered embeds Numeric and adds ~string. The union operator works between named constraints too, so Numeric | ~string reads as "everything in Numeric, plus string-like types." This is a clean way to layer constraints: a small base set, then specialized supersets.
Third, the line wrapping inside Numeric uses the regular Go | operator across lines. The compiler doesn't care; it parses one big union. Splitting by category (integer signed, integer unsigned, float) makes the constraint easier to read.
Once you have these names, generic functions become very short:
Each one declares what it needs by name. A reader scanning the signatures can tell immediately whether the function is "numeric arithmetic" or "ordered comparison." The constraint name becomes documentation.
The diagram shows the layering. Numeric is the base; Ordered is a superset that adds string. Functions pick whichever level they need: Sum needs arithmetic so it uses Numeric, Min needs < so it uses Ordered. New functions slot into the right level without redefining the union from scratch.
You don't have to invent these constraints yourself. The Go team ships Ordered in the standard library's cmp package (cmp.Ordered, since Go 1.21), and a broader set (Integer, Signed, Unsigned, Float, Complex, Ordered) lives in golang.org/x/exp/constraints. For now, knowing how to declare your own is enough.
Type sets are one of the corners of the language where small mistakes produce confusing error messages. Knowing the common ones in advance saves debugging time.
Pitfall 1: Forgetting `~` and rejecting your own named types.
This mistake appears most often. You write interface { int64 } because that's what the values "are," then your code defines type Cents int64 and the constraint stops accepting it. The error message is unusually helpful here:
The fix is to write ~int64 instead of int64. Use ~ by default for any constraint that's meant to be general-purpose. Only drop the ~ if you have a reason to forbid defined types.
Pitfall 2: Mixing types that don't share an operator you need.
The compiler reports:
bool doesn't support +, so + isn't available inside the body. The lesson: pick a type set whose members all support every operator the body needs. When in doubt, write a one-line Numeric union and stop trying to be clever.
Pitfall 3: Putting a non-interface type in constraint position.
Constraints have to be interface types (or constraint interfaces, which are still interfaces). You can't write:
Well, actually, that does compile in modern Go. Inline type sets are allowed as a shorthand. But this still fails:
A type alias to int isn't a constraint; it's a synonym for the type int. A constraint has to be an interface. The fix is to wrap your set in an interface:
Readers who think of constraints as "the type or types T can be" run into this. A constraint is always an interface describing a type set. The values you can pass are inferred from the type set, not declared directly.
Pitfall 4: Confusing `int` and `int32` (or `int` and `int64`).
int and int32 are distinct types in Go, even on 32-bit platforms. ~int covers int and types defined on top of int, but not int32. If you want all signed integer widths, you have to list them: ~int | ~int8 | ~int16 | ~int32 | ~int64. This is the main reason general-purpose Numeric constraints are so long. Each width is its own type.
Pitfall 5: Mixing methods and a type set where the method conflicts with the underlying type.
A type-element line can constrain T to be, say, ~int64. But int64 doesn't have any methods, so if you also require a String() string method, T has to be a defined type on top of int64 with String. You can't pass a raw int64. This is fine when intended, but if you wanted "any int64-shaped thing, with or without String," the constraint is wrong; drop the method requirement.
The cleanest rule: think about the intersection. Every requirement narrows the set. If you find yourself adding requirements and the set becomes empty (or close to it), one of them is wrong.
Pitfall 6: Using a type set where `any` would do.
len works on slices regardless of element type. The constraint here is pure noise. The function doesn't use any operator that requires knowing the element type. Just write:
Avoid a type set unless you need an operator that requires it. Method-based or any-based constraints are simpler and cover more cases.
10 quizzes