Skip to content
academia.sh

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.
  • ~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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close