Type Identity and Assignability
Two questions sit underneath every Go assignment, function call, and
==comparison: are these two types identical (literally the same type), and if not, is a value of one assignable to a variable of the other? The Go language specification (go.dev/ref/spec) answers both with precise, closed rule sets. The key idea — and the one that surprises newcomers — is that Go’s type identity is mostly nominal: a named type is always different from any other type, even one with an identical structure.type Celsius float64andtype Fahrenheit float64are different types and you cannot assign one to the other without a conversion, despite both being “really”float64. Assignability is the broader, more permissive relation that nonetheless occasionally lets values cross type boundaries without an explicit conversion — and knowing exactly when is the difference between code that compiles and code that does not.
Mental Model
Hold three distinct relations apart. Identity is the strictest: “these are the same type.” Assignability is “a value of type V may be stored in a variable of type T with no conversion written.” Convertibility (a separate, looser relation, not the focus of this note) is “you may write T(x) to force the cross.” Identity implies assignability; assignability implies convertibility. The whole type system’s rigidity or flexibility at any given line comes down to which of these three the compiler is checking.
graph TD A["Is V identical to T?"] -->|yes| OK["assignable — no conversion"] A -->|no| B["Same underlying type,<br/>and one of V/T is unnamed,<br/>neither a type parameter?"] B -->|yes| OK B -->|no| C["T is an interface and<br/>x implements T?"] C -->|yes| OK C -->|no| D["x is untyped constant<br/>representable as T?<br/>or x is nil and T is a<br/>pointer/func/slice/map/chan/iface?"] D -->|yes| OK D -->|no| FAIL["NOT assignable —<br/>explicit conversion required"]
Diagram: the assignability decision procedure, simplified to the non-type-parameter cases. The insight: most “why won’t this assign?” errors fail at box B — the programmer has two named types with the same underlying type and expects them to interchange. The “at least one unnamed” escape hatch is what makes box B sometimes succeed (e.g. assigning a []int literal type to a named slice type) and the rest of the time fail.
Underlying Types — the Foundation
Every rule below rests on the notion of an underlying type. The spec defines it: “If T is one of the predeclared boolean, numeric, or string types, or a type literal, the corresponding underlying type is T itself. Otherwise, T’s underlying type is the underlying type of the type to which T refers in its declaration. For a type parameter that is the underlying type of its type constraint, which is always an interface.”
Read that recursively. The underlying type of int is int (a predeclared type — it is its own underlying type). The underlying type of []string is []string (a type literal — itself). For type Celsius float64, Celsius refers to float64, whose underlying type is float64; so Celsius’s underlying type is float64. For type Temp Celsius, follow the chain again: Temp → Celsius → float64, so Temp’s underlying type is also float64. The underlying type is what you get by peeling every named layer until you hit a predeclared type or a type literal.
A related spec term is named type (the spec also uses defined type for the subset created by a type declaration). Named types are: the predeclared types (int, string, …), defined types (anything from type X ...), and type parameters. Everything else — []int, map[K]V, *T, struct{...}, chan T, func(...), interface{...} written as a literal — is an unnamed (composite/literal) type. This named-versus-unnamed split is load-bearing in the assignability rules.
Type Identity — the Rules
The spec: “A named type is always different from any other type. Otherwise, two types are identical if their underlying type literals are structurally equivalent; that is, they have the same literal structure and corresponding components have identical types.” The first sentence is decisive — two distinct named types are never identical, full stop. Identity matters only among unnamed types, and there it is structural, defined component-by-component:
- Arrays — identical element types and the same length.
[3]int≠[4]int. - Slices — identical element types.
[]intis identical to any other[]int. - Structs — same sequence of fields, with matching names, identical field types, identical tags, and matching embedded/not-embedded status. Struct tags are part of identity —
struct{X int \a`}andstruct{X int `b`}` are different types. And non-exported field names from different packages are always different, so two structurally identical structs declared in different packages with a lowercase field are not identical. - Pointers — identical base types.
- Functions — same number of parameters and results, identical corresponding types, and both variadic or both not. Parameter and result names need not match.
- Interfaces — identical if they define the same type set (the Go 1.18 generics-era definition; pre-1.18 it was “same method set”).
- Maps — identical key types and identical element types.
- Channels — identical element types and the same direction.
chan int,chan<- int,<-chan intare three different types. - Instantiated generic types — identical defined types and all type arguments identical.
List[int]is identical toList[int], never toList[string].
The practical upshot: the tag rule trips up code that copies a struct’s shape but changes a tag; the variadic rule means func(...int) and func([]int) are different function types; the channel-direction rule is why a chan<- int parameter cannot be passed a value typed chan int by identity (though assignability rescues it — see below).
Assignability — the Rules
The spec lists, verbatim, the conditions under which “a value x of type V is assignable to a variable of type T”. At least one must hold:
- V and T are identical. The trivial case.
- V and T have identical underlying types, are not type parameters, and at least one of V or T is not a named type. This is the crucial escape hatch. Assigning a
[]intliteral-typed value to a variable oftype IntSlice []intworks —V([]int) is unnamed,T(IntSlice) is named, underlying types match. But assigningCelsiustoFahrenheitfails — both are named, so the “at least one unnamed” clause is not satisfied. - V and T are channel types with identical element types, V is a bidirectional channel, and at least one of V or T is not a named type. This is what lets you pass a
chan intwhere achan<- intis wanted: the bidirectional channel narrows to a directional one automatically. - T is an interface type (not a type parameter) and x implements T. Any value whose method set satisfies the interface is assignable to it — the mechanism behind every
var w io.Writer = os.Stdout. - x is the predeclared identifier
nil, and T is a pointer, function, slice, map, channel, or interface type (not a type parameter).nilis assignable to exactly the nillable types. - x is an untyped constant representable by a value of type T.
var f float64 = 3works because the untyped constant3is representable asfloat64(see Representability below).
For Go 1.18+ generics, three additional clauses cover type parameters, since a type parameter V or T stands for every type in its constraint’s type set: nil is assignable to a type-parameter T if it is assignable to each type in T’s type set; an unnamed V is assignable to a type-parameter T if assignable to each type in T’s type set; and a type-parameter V is assignable to an unnamed T if every type in V’s type set is assignable to T. The principle is “the rule must hold for the whole set, because the parameter could be any of them.”
Representability — Why var x int32 = 100 Works
Rule 6 above depends on representability, the spec’s notion for “can this constant fit in this type.” The spec: a constant x is representable by a value of type T (T not a type parameter) if x is in the set of values determined by T; or T is a floating-point type and x rounds to T’s precision without overflow (IEEE 754 round-to-even, negative zero simplified to unsigned zero); or T is complex and both real(x) and imag(x) are representable in T’s component type. For a type parameter T, x must be representable by each type in T’s type set.
This is why var b byte = 255 compiles but var b byte = 256 is a compile error — 256 is not in byte’s value set. It is a constant-time check the compiler performs; no run-time overflow is involved.
Code Examples
type Celsius float64
type Fahrenheit float64
type IntSlice []int
func main() {
var c Celsius = 20
var f Fahrenheit
f = c // line 1: COMPILE ERROR — both named, rule 2 fails
f = Fahrenheit(c) // line 2: OK — explicit conversion (convertibility)
var raw []int = []int{1, 2, 3}
var named IntSlice = raw // line 3: OK — raw's type []int is unnamed, rule 2
var ch chan int = make(chan int)
var send chan<- int = ch // line 4: OK — rule 3, bidirectional narrows
var x int32 = 100 // line 5: OK — untyped const 100 representable, rule 6
var y int32 = 1 << 40 // line 6: COMPILE ERROR — not representable as int32
}- Line 1 — fails:
CelsiusandFahrenheitare both named, so assignability rule 2 (“at least one unnamed”) is not satisfied even though both underliefloat64. - Line 2 — succeeds because conversion is a looser relation than assignability;
Fahrenheit(c)explicitly crosses the boundary. - Line 3 — succeeds: the right-hand side has the unnamed literal type
[]int, the variable has the named typeIntSlice, underlying types match, rule 2 applies. - Line 4 — succeeds via rule 3:
chis a bidirectionalchan int, and assignment automatically restricts it to the send-onlychan<- int. - Line 5 — succeeds via rule 6:
100is an untyped constant representable asint32. - Line 6 — fails:
1 << 40exceedsint32’s value set; representability is checked at compile time.
Failure Modes and Common Misunderstandings
“Same underlying type means interchangeable.” The single most common error. Two defined types with the same underlying type are not assignable to each other — by design, so that type Meters int and type Seconds int cannot be accidentally mixed. Rule 2 only helps when one side is unnamed.
Struct tags silently break identity. Copying a struct definition and tweaking a tag produces a different type; a function expecting the original will reject the copy. Tools that re-generate structs (protobuf, ORM codegen) can produce this mismatch.
Channel direction is part of the type. chan int, chan<- int, <-chan int are three types. You can assign a bidirectional channel into a directional variable (rule 3) but not the reverse, and the identity rule treats all three as distinct — relevant when reflecting or comparing types.
Cross-package unexported fields. Two structs that look identical but live in different packages and have a lowercase field are not identical types — the spec makes non-exported names package-scoped for identity purposes.
Untyped vs typed constants. var x int32 = 100 works (untyped 100); var n int = 100; var x int32 = n does not — once n has the named type int, rule 6 no longer applies and int≠int32 fails identity. The escape is an explicit int32(n).
Alternatives and When to Choose Them
When two named types should interchange, the language gives you conversion (T(x)) — explicit, deliberate, and the right tool when units genuinely match. When you want one type to accept many, use an interface: assignability rule 4 makes any implementer assignable. When you want code that is polymorphic over a set of types without conversions, use type parameters with a constraint — the type-parameter assignability clauses then apply the rules set-wide. The deliberate friction of nominal identity is a feature: it is what stops a UserID from being assigned where an OrderID is expected.
Production Notes
These rules are enforced entirely at compile time by the gc compiler’s type checker (see Go Type Checking); there is no run-time cost to identity or assignability. They are also exposed reflectively: reflect.Type has AssignableTo(u Type) bool and ConvertibleTo(u Type) bool methods that answer exactly these questions for a reflect.Type pair — see Reflection in Go. The == comparability of types is a separate concern governed by its own spec section (a type is comparable if == is defined on it), and reflect.Type.Comparable() reports it. The named-type-identity rule has been stable since Go 1; the Go 1.9 type-alias feature (type A = B, with =) is the one way to make a second name that is identical rather than a distinct defined type — an alias introduces no new type at all, so A and B are the same type and freely assignable.
See Also
- Go Type System — the parent overview of how Go types are organized
- Interfaces in Go — assignability rule 4, satisfaction by method set
- Type Sets and Constraints — interface identity by type set, generics-era
- Type Assertions and Type Switches — the run-time counterpart of these compile-time checks
- Generics and Type Parameters — the type-parameter assignability clauses
- Value Semantics and Pointer Semantics — what an assignment physically copies
- Reflection in Go —
AssignableTo/ConvertibleToexpose these rules at run time - Go Type Checking — the compiler stage that enforces all of this
- Go Internals MOC — parent map of content