---
title: 'Type Constraints'
source: 'https://academia.sh/en/courses/go-type-system/type-constraints'
course: 'Interfaces, Errors and Generics'
language: en
updated: '2026-09-14T10:59:34+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

# 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.

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.

```go
// 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.

```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.

```go
// 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.
- `~T` covers not only `T` itself but every named type whose underlying type is `T`; 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.
