Lesson 11 / 13
Type Constraints
A type constraint is an interface too, but it lists a type set, not a method: when the same three constraints are tested with four candidates, IntOnly accepts only int, UnderlyingInt (carrying ~int) also accepts named types whose underlying type is int, and the union ~int|~float64 named Numeric accepts both at once — 1/4, 2/4, 3/4. The limiting measurement shows that an interface listing a type set cannot be used as an ordinary value type, while an interface listing only a method can be both a constraint and a value type.
Published Updated
In this lesson
- Shows, by asking the compiler about three constraints, that a type constraint is an interface listing a type set, not a method
- Measures, by testing four candidates, that the ~ mark also covers named types sharing the same underlying type
- Bounds, by comparing against a method-listing interface, that an interface listing a type set is valid only in constraint position
Contents
The Generic Functions lesson measured that an unconstrained type parameter is nearly useless in
the body: with any, neither == nor + compiled, only operations looking at the slice’s own
structure, such as len, passed. That lesson left the mechanism filling this gap open with a
single sentence: a type constraint is an interface that states explicitly in the source the
set of types a type parameter accepts and the operations that set guarantees. This lesson opens
that sentence and asks the same question again: where does this constraint sit in the source,
and how do we know what it accepts?
The answer is short: the constraint sits in the declaration. In the common measurement, “the constraint’s declaration” is one of four declaration examples — alongside embedding, the compile-time conformance proof, and a generic type’s instantiation, one of the four bindings written explicitly in the source. This lesson measures how that binding is written and what it bounds: which types a constraint accepts, whether widening that acceptance has a cost, and where else the constraint interface itself can be used.
A Constraint Lists a Type Set
GE5. An ordinary interface lists a method set: a type saying interface{ Write() string } accepts every type that has a method named Write. A type constraint is also, syntactically,
an interface — it is written interface{ ... } — but instead of a method signature, type
names can be written inside the braces: interface{ int | float64 } defines a type set
that accepts only types that are int or float64. This is the same syntax used for two
separate jobs: one says “provide these methods,” the other says “be one of these types.”
The ~ mark widens this set. ~int covers not only int itself, but every named type whose
underlying type is int — once type Age int is written, Age now falls into ~int’s
set, but not into int’s set, because Age and int are not the same type, they only share
the same underlying type. The measurement below tests three constraints against four
candidates: int itself, a named type whose underlying type is int (Age), float64, and
string, which fits none of them.
// constraint.go — how many types three constraints accept is counted by asking the compiler
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
func compiles(source string) bool {
dir, err := os.MkdirTemp("", "trial")
if err != nil {
return false
}
defer os.RemoveAll(dir)
os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module trial\n\ngo 1.24\n"), 0o644)
os.WriteFile(filepath.Join(dir, "code.go"), []byte(source), 0o644)
cmd := exec.Command("go", "build", "./...")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
return cmd.Run() == nil
}
type constraintDef struct{ name, body string }
type typeDef struct{ name, declaration, value string }
func main() {
constraints := []constraintDef{
{"IntOnly", "interface{ int }"},
{"UnderlyingInt", "interface{ ~int }"},
{"Numeric", "interface{ ~int | ~float64 }"},
}
types := []typeDef{
{"int", "", "3"},
{"Age (underlying int)", "type Age int\n", "Age(3)"},
{"float64", "", "3.5"},
{"string", "", `"x"`},
}
fmt.Printf("%-16s", "constraint")
for _, t := range types {
fmt.Printf("%-22s", t.name)
}
fmt.Println()
for _, c := range constraints {
fmt.Printf("%-16s", c.name)
accepted := 0
for _, t := range types {
source := fmt.Sprintf("package trial\n%stype K %s\nfunc F[T K](a T) {}\nfunc G() { F(%s) }",
t.declaration, c.body, t.value)
ok := compiles(source)
result := "no"
if ok {
accepted++
result = "yes"
}
fmt.Printf("%-22s", result)
}
fmt.Printf("%d/4\n", accepted)
}
}
constraint int Age (underlying int) float64 string IntOnly yes no no no 1/4 UnderlyingInt yes yes no no 2/4 Numeric yes yes yes no 3/4
GE6. The three rows, tested against the same four candidates, produce three different
acceptance counts based on how the constraint is written in the source. IntOnly accepts only
int itself — Age is rejected even though its underlying type is int, because it is a
separate type; this is the same rigidity as two arrays being counted as separate types when
their lengths differ (Arrays and Slices lesson, M09/K01). UnderlyingInt writes ~int, so it
also accepts Age, and the acceptance count rises to two. Numeric lists the union (|) of
two ~ terms, so it covers both the integer and the floating-point side, rising to three —
string is rejected by all three, because no term covers it. This rise in the acceptance count
can be read directly from the source: IntOnly’s body has one term, UnderlyingInt’s has one
term but with a tilde, Numeric‘s has a union of two terms. How wide a constraint is can be
stated in advance, with no call made at all, just by looking at the number of terms and the
tilde mark **in the constraint’s own declaration**.
This measurement also shows where the binding surfaces when it breaks: when a caller tries to
instantiate a generic function with a type outside the constraint’s set, the error surfaces not
at run time but at compilation — every “no” in the table is a compile rejection, proof that
trying to call a function constrained by IntOnly with a string argument ends not with a
panic but with compilation stopping.
The constraint itself also works with an implicit-satisfaction model: the Age type nowhere
says “I satisfy UnderlyingInt,” not even the line type Age int mentions a constraint at
all. Whether a type falls into a constraint’s set is determined from the outside, by looking not
at that type’s own source but at its underlying type — the same mechanism the Interface Concept
lesson measured as implicit satisfaction, working here through type identity instead of
methods. The difference is here: to implicitly satisfy an interface, a type has to write
certain methods; to implicitly satisfy a constraint, a type does not need to write anything at
all — it is enough for its underlying type to match one of the terms the constraint lists. The
line defining the Age type could have been written before the constraint was even defined; once
the constraint came along later, Age automatically fell into its set, because membership looks
at Age’s underlying type, not its source.
The structure formed by terms separated with | is called a union: the type set a
constraint accepts is the union of each term’s own set. Numeric’s set is the union of ~int’s
set and ~float64’s set; it is enough for a type to fall into one of these two subsets, it
does not need to fall into both. Union terms can be multiplied as much as desired (~int | ~int8 | ~int16 | ~int32 | ~int64), but every term only widens the set, no term ever takes
back an existing membership — this is why the table above shows a one-directional rise, from 1
to 2, from 2 to 3; no constraint accepts fewer types than the one before it.
As a constraint widens, the guarantee it gives narrows: IntOnly accepts only one type but
guarantees every int operation on it (including bit shifting); Numeric accepts three more
types but guarantees only the operations both families support in common (+, -, *, /,
comparison operators) — since bit shifting is not defined for float64, it cannot be written in
a body constrained by Numeric. The constraint’s breadth in the source is inversely
proportional to the narrowness of operations the body can write; this is a more moderate version
of the extreme case the Generic Functions lesson showed with the any constraint (no operation
at all can be written).
comparable: The Built-in Constraint That Closes the Gap
The Generic Functions lesson’s final measurement showed that a source writing a == b with an
unconstrained T does not compile. The reason can now be read from the source: any carries no
type-set term at all, it only says “every type is accepted,” and gives no promise at all that
the == operator exists. The language closes this gap with its own built-in constraint:
comparable defines a type set covering every type that supports the == and != operators —
except slice, map, and function types, because these three can never be compared with == in
Go.
// closed.go — the comparable constraint closes the previous lesson's unconstrained == error
package main
import "fmt"
func Equal[T comparable](a, b T) bool { return a == b }
func main() {
fmt.Println("Equal(3, 3) :", Equal(3, 3))
fmt.Println("Equal(\"x\", \"y\") :", Equal("x", "y"))
}
Equal(3, 3) : true
Equal("x", "y") : false
GE7. Equal here has exactly the same body as the previous lesson’s Equal[T any] writing —
the only difference is the constraint’s name. This single word carries the information needed
for the == operator to be usable in the body; comparable, unlike any, is not part of the
standard library, it is a member of the language’s own syntax, just like any itself. Where the
binding is built here is two-layered: which operations the constraint allows sits in the
constraint’s own declaration (was comparable or any chosen), but which concrete type
that allowed operation will work for is again built at the call site, during instantiation.
The Limiting Measurement: A Constraint Interface Is Valid Only in Constraint Position
All three constraints above are interfaces. An ordinary interface can be used for two jobs: as a
variable’s type (var x Writer) and as a type parameter’s constraint (func F[T Writer](...)).
Whether a constraint listing a type set can do only one of these two jobs is the subject of the
measurement below.
// limit2.go — a constraint interface cannot be used as a value type, measured by asking the compiler
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
func compiles(source string) bool {
dir, err := os.MkdirTemp("", "trial")
if err != nil {
return false
}
defer os.RemoveAll(dir)
os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module trial\n\ngo 1.24\n"), 0o644)
os.WriteFile(filepath.Join(dir, "code.go"), []byte(source), 0o644)
cmd := exec.Command("go", "build", "./...")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
return cmd.Run() == nil
}
func classifySource(source string) string {
if compiles(source) {
return "compiled"
}
return "did not compile"
}
func main() {
numericConstraint := `package trial
type Numeric interface{ ~int | ~float64 }
func Add[T Numeric](a, b T) T { return a + b }`
numericValue := `package trial
type Numeric interface{ ~int | ~float64 }
func F() { var x Numeric; _ = x }`
writerConstraint := `package trial
type Writer interface{ Write() string }
func F[T Writer](a T) {}`
writerValue := `package trial
type Writer interface{ Write() string }
func F() { var x Writer; _ = x }`
fmt.Printf("%-38s %-13s %s\n", "interface", "constraint pos.", "value type")
fmt.Printf("%-38s %-13s %s\n", "Numeric (lists a type set)",
classifySource(numericConstraint), classifySource(numericValue))
fmt.Printf("%-38s %-13s %s\n", "Writer (lists a method)",
classifySource(writerConstraint), classifySource(writerValue))
}
interface constraint pos. value type Numeric (lists a type set) compiled did not compile Writer (lists a method) compiled compiled
GE8. Numeric compiles in a constraint position ([T Numeric]) but does not compile as an
ordinary variable type (var x Numeric): an interface carrying a type-set term, as far as the
source says, can exist only as a constraint. Writer, on the other hand, compiles in both:
a method-listing interface has no type-set restriction, so it can both carry a value and be a
constraint. The interface’s two jobs split apart here: contract (which methods exist) and
constraint (which types are valid) are written with the same syntax, but the moment a
type-set term is added, the interface can do only the second job.
This distinction does not break the model the Interface Concept lesson built, it adds a boundary
to it: an interface’s implicit satisfaction is still valid, but where the interface itself
can be used now depends on the terms in the interface’s body. Numeric does not say what a type
can do, it says which types it can be — and that is a different question from a method
contract. The Add[T Numeric] function knows that T supports the + operator, because every
term in the constraint’s type set (~int, ~float64) supports that operator; but this
knowledge comes not from a method call, it comes from set membership.
In this lesson two places were placed side by side, and the difference was shown by running it in both: the constraint itself sits in the declaration (how many terms, which tilde, which union), but which concrete type the constraint applies to is again built at the use site, during instantiation. The Generic Functions lesson measured this second place on its own; this lesson added the declaration side to it and showed how the two work together — without a constraint the use site allowed no operation at all; with a constraint, which operations are allowed can be read in advance, from the source.
That these two jobs are written with the same syntax is a direct consequence of the constraint concept not being a separate structure added to Go afterward: instead of opening a new keyword, the language widened the existing interface syntax to also accept type terms. The cost of this was seen in the measurement above — one end of the widened syntax (adding a type term) closes the other end (using it as a value type). Whoever writes a constraint therefore has to think about two questions together: how wide should the types the constraint accepts be, and will this constraint ever need to be used as an ordinary value type one day. If the answer to the second question is yes, the constraint should carry no type term — it should list only methods; if the constraint exists only to bound type parameters, adding a type term has no cost, because it is not expected to be used for anything else anyway.
Summary
- A type constraint is, syntactically, an interface, but it lists a type set instead of a method; which types are accepted is read from the constraint’s own declaration.
~Tcovers not onlyTitself but every named type whose underlying type isT; terms joined with|widen the set.- When the same four candidates are tested against three constraints, the acceptance count rises with the number of terms in the constraint and the tilde mark: 1/4, 2/4, 3/4.
- When a constraint is violated — a caller instantiates with a type outside the set — the binding surfaces not at run time but at compilation.
- An interface carrying a type-set term is valid only in constraint position; a method-listing interface can be both a constraint and an ordinary value type.
- An interface’s two jobs — contract and constraint — are written with the same syntax, but the moment a type-set term is added, the interface narrows to only the second job.
A constraint widens the narrowest boundary drawn by unconstrained any, but it does not remove
the boundary entirely: however widely a constraint is written, the body can still use only the
operations the constraint guarantees. This shows where the cost of type safety goes — the
safety comes from bounding, in advance, in the constraint’s declaration, what the body is
allowed to write.
Next Step
A constraint bounds, in the declaration, which types a type parameter accepts; but the
constraint itself does not say where a data structure built with that type parameter will be
instantiated. The next lesson measures this: when a generic struct is instantiated twice, as
Stack[int] and Stack[string], whose declaration — the writer’s or the user’s — does this
instantiation sit in, and what does that give type safety.
To keep your progress and take notes, Log in
My notes
Log in to take notes.