skip to content

What does the tilde in a Go constraint element like ~int mean, and why does a defined type fail int | string?

level: middleimportance: should knowfreq 58%

answer

  1. a defined type is not its underlying type
  2. the tilde talks about layout, not identity
  3. cmp.Ordered puts one on every term
  4. the compiler hints that a ~ may be missing

basics

~20 s

The term ~int means any type whose underlying type is int, so it matches int and defined types such as type PlayerID int. A bare int term matches only int itself, which is why a defined type is rejected by int | string.

solid answer

~50 s

Every Go type has an **underlying type**: for `type PlayerID string` the underlying type is `string`, but `PlayerID` is a distinct type and is not `string`. A constraint term written as a plain type name, like `string`, matches only that exact type. A term written `~string` is an *approximation*: its type set is every type whose underlying type is `string`, so `PlayerID` is included. That is why `cmp.Ordered` writes `~int | ~int8 | ... | ~string` — without the tildes it would reject every domain type a caller declares. Two restrictions matter: in `~T`, `T` must be its own underlying type (`~PlayerID` is illegal — write `~string`), and `T` may not be an interface. When you forget a tilde the compiler says the type does not satisfy the constraint and usually hints that a `~` may be missing.

code

go · 18 lines
go
type PlayerID string

type Key interface {
	~string | ~int64 // matches PlayerID
}

type ExactKey interface {
	string | int64 // matches only string and int64
}

func Rank[K Key](ids []K) {}

func RankExact[K ExactKey](ids []K) {}

func use(ids []PlayerID) {
	Rank(ids)      // compiles
	// RankExact(ids) // PlayerID does not satisfy ExactKey
}

go deeper

for a junior

Know that Go's defined types, like type Score int, are distinct types, and that a tilde in a constraint is what lets a generic function accept them.

for a middle

Explain underlying types, the difference between an exact and an approximation term, and why the standard ordering constraint puts a tilde on every one of its terms.

for a senior

Be able to read the compiler's does-not-satisfy error and its missing-tilde hint, then decide whether the correct fix is widening the constraint or converting at the call site.

for a principal

Frame it as an API promise: approximated terms invite every caller's domain type into your generic code, exact terms keep the accepted set closed and reviewable. Decide which one your package can support long term.

## Defined types and underlying types A declaration like ```go type PlayerID string ``` creates a **defined type**. `PlayerID` is a brand-new type, not an alias: it is assignment-incompatible with `string` and can carry its own methods. What it shares with `string` is its **underlying type** — the structural type it was declared from. `string`'s underlying type is itself; `PlayerID`'s underlying type is `string`. Domain code in Go is full of these: `type Score int`, `type Millis int64`, `type PlayerID string`. They are the whole point of Go's type system — a compiler-checked distinction between two things that are both, structurally, an `int`. ## What a constraint term matches Inside a constraint, a term may be written two ways. - **Exact term**: `string`. Its type set is the single type `string`. `PlayerID` is not in it. - **Approximation term**: `~string`. Its type set is every type whose underlying type is `string` — `string` itself, `PlayerID`, and any other defined type over `string`. So a constraint written `interface{ int | string }` is far narrower than it looks. A generic ranking helper with that constraint compiles fine until the first caller passes a `[]PlayerID`, at which point the compiler reports that `PlayerID` does not satisfy the constraint and typically adds a hint that the constraint may be missing a `~` for `string`. Reading that hint literally is the fix: change the constraint to `~int | ~string`. This is exactly why the standard library's ordering constraint is written with tildes on every term. `cmp.Ordered` is ```go type Ordered interface { ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr | ~float32 | ~float64 | ~string } ``` Without the tildes, sorting a `[]Score` would be impossible. ## The two rules about writing ~T 1. **`T` must be its own underlying type.** `~PlayerID` is a compile error, because `PlayerID`'s underlying type is `string`, not `PlayerID`. Write `~string`. The rule exists so that `~T` always denotes a clean structural family rather than a chain of defined types. 2. **`T` may not be an interface.** `~error` or `~fmt.Stringer` is rejected; approximation is about representation, and an interface has none of its own. Aliases behave differently from defined types and are a common source of confusion. `type Score = int` declares an *alias*: `Score` simply is `int`, so it matches the exact term `int` and the approximation `~int` equally. Only defined types create the mismatch that the tilde solves. ## When to use an exact term instead Approximation is the default choice for numeric and string constraints, but exact terms are the right tool when the identity of the type matters rather than its layout. If a library exposes a fixed set of key types it knows how to serialise, `interface{ string | int64 }` says "exactly these two, no domain wrappers" and the compiler enforces it. That is a deliberate API decision: it keeps the implementation's assumptions true, at the cost of forcing callers to convert. A library author choosing between them can ask one question: does the code care about the *representation* (add them, order them, index them) or about the *identity* (I will encode exactly these, or I will attach meaning to the type)? Representation wants `~`; identity does not. ## Operators still come from the whole set The tilde widens membership, it does not change what the body may do. With `~string | ~int64`, the permitted operations are those that both `string` and `int64` support with the same meaning: `==`, `!=`, ordering comparisons and `+`. There is no `-`, because `string` has none. Widening a constraint with tildes never loses operators; adding a new term to the union can. ## Conversion inside the body A value of a type parameter constrained by `~string` cannot be passed to a function expecting `string` without an explicit conversion, `string(v)`. The constraint tells the compiler how the value is laid out, not that it is interchangeable with the underlying type. That conversion is free at run time but must be written.

  • Is ~PlayerID legal when PlayerID is declared as type PlayerID string?
    No. In a term `~T`, `T` must be its own underlying type, and `PlayerID`'s underlying type is `string`. The compiler rejects `~PlayerID`; write `~string`, which already includes `PlayerID` in its type set.
  • Does ~int match a type declared with type Score = int?
    Yes, but only because that is an alias rather than a defined type. `Score` *is* `int`, so it satisfies both the exact term `int` and the approximation `~int`. Aliases never create the mismatch that tildes exist to solve.
  • When would you deliberately leave the tilde off?
    When the type's identity matters, not its layout — for example a library that promises to encode exactly `string` and `int64` keys and does not want callers' wrapper types flowing in unnoticed. Exact terms make that promise compiler-enforced, at the cost of forcing callers to convert.

saying these in an interview costs you the question

  • Says ~int means anything convertible to int
  • Expects a plain int term to match type Score int
  • Writes ~PlayerID for a defined type
  • Thinks ~string also matches []byte because both hold text