Go Type System

Go’s type system is statically typed, structurally satisfied for interfaces, nominally distinct for everything else, and built without inheritance. Every value has exactly one type fixed at compile time; the language specification is the authoritative description of how types are formed, when two are identical, when a value is assignable or convertible, and what methods a type carries. The system rests on a small set of orthogonal ideas — a type literal composes types; a type definition mints a new, distinct defined type; every type has an underlying type that drives identity and conversion; methods attach behavior to defined types; and interfaces describe behavior structurally so any type that has the right methods satisfies them with no declaration. This note is the entry point to Section 11: it frames those ideas and links down to the notes that develop each one.

Mental Model

The single most useful frame: in Go, types are kept distinct on purpose, and behavior is matched structurally on purpose. Two type declarations with the same shape produce two different types you cannot mix without an explicit conversion — that is the nominal half, and it exists so the compiler catches “I passed a Celsius where a Fahrenheit was expected.” But an interface is satisfied by any type that happens to have the required methods, with no implements keyword — that is the structural half, and it exists so packages can interoperate without depending on each other’s type names. Go is deliberately nominal for data and structural for behavior.

This note describes the type system as a language feature — what types are, how they relate, what the spec guarantees. The mechanism that enforces these rules at compile time — name resolution, bottom-up expression typing, and rule checking — is a separate compiler phase covered in Go Type Checking; read this note for the rules, that one for how the compiler proves them.

A second frame: there is no inheritance. There is no base class, no extends, no subtype hierarchy among concrete types. What other languages get from inheritance, Go splits into two unrelated mechanisms: interfaces (for polymorphism — many concrete types behind one abstract type) and embedding (for code reuse — promoting the fields and methods of an inner type into an outer one). A defined type created from another type does not inherit the original’s methods; the spec is explicit: “It does not inherit any methods bound to the given type.” This is why Go has no fragile-base-class problem and why []int and a type IntSlice []int need a conversion to interchange.

The third frame — the one that explains the most “surprising” behavior — is named type vs underlying type. Every type T has an underlying type: for the predeclared types (int, string, bool, …) and for type literals ([]byte, struct{...}, map[K]V, …) the underlying type is itself; for a defined type it is, recursively, the underlying type of whatever it was defined from. Identity and assignability and convertibility are all defined in terms of underlying types and named-ness. Once you internalize “the spec keeps asking: are the underlying types equal, and is at least one side not a named type?”, the rules stop being a list to memorize and become one rule applied repeatedly.

graph TD
    Lit["Type literals<br/>[]byte · struct{...} · map[K]V · chan T · func(...)<br/>underlying type = itself"]
    Pre["Predeclared types<br/>int · string · bool · float64 ...<br/>underlying type = itself"]
    Def["Defined type — type T2 T1<br/>distinct from T1<br/>underlying type = underlying(T1)"]
    Alias["Alias — type A = T<br/>A and T are the SAME type"]
    Pre --> Def
    Lit --> Def
    Def --> Def2["type T3 T2<br/>also distinct, same underlying"]
    Pre --> Alias
    Lit --> Alias
    Def --> Alias

Diagram: how types come into being. Predeclared types and type literals are the roots, their own underlying type. A type T2 T1 definition makes a brand-new, distinct type that nonetheless shares T1’s underlying type — that shared underlying type is what later permits conversion between them. An alias (type A = T, since Go 1.9) is not a new type at all — A and T are interchangeable names for one type. The insight: “distinct identity” and “shared underlying type” are independent axes, and almost every type-system rule reads off both.

Mechanical Walk-through

Type literals and defined types

The spec splits how a type is denoted into two: a type name (an identifier, possibly with type arguments if generic) and a type literalArrayType, StructType, PointerType, FunctionType, InterfaceType, SliceType, MapType, ChannelType (Types). A type literal composes a type from existing ones; []int, *Node, map[string]bool, struct{ x int } are all anonymous types you can use directly.

A type definitiontype Point struct{ x, y float64 }“creates a new, distinct type with the same underlying type and operations as the given type and binds an identifier, the type name, to it” (Type definitions). The result is a defined type; the spec stresses it is “different from any other type, including the type it is created from.” So type Celsius float64 and type Fahrenheit float64 are three different types — Celsius, Fahrenheit, and float64 — even though all three share the underlying type float64.

Named types

“Predeclared types, defined types, and type parameters are called named types” (Types). An alias denotes a named type if the aliased type is a named type. Type literals ([]int, struct{...}) are not named. This named/unnamed distinction is load-bearing: several rules permit something only when at least one side is not a named type.

Underlying types

“Each type T has an underlying type: 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” (Underlying types). Worked through: for type B1 string; type B2 B1, the underlying type of both B1 and B2 is string; for type B3 []B1; type B4 B3, the underlying type of both is the literal []B1.

Identity, assignability, convertibility

These three relations form a ladder of strictness:

  • Identity (Type identity): “A named type is always different from any other type.” Otherwise two types are identical when their underlying type literals are structurally equivalent. So Celsius and Fahrenheit are not identical (both named, both distinct); []int and []int are.
  • Assignability (Assignability): when you may write var t T = x with no conversion. The key clause: identical underlying types and at least one of V, T is not a named type. So you may assign a []int (unnamed) to an IntSlice variable and vice versa, but you may not assign a Celsius to a Fahrenheit variable. The interface clause is separate: any x is assignable to an interface T it implements.
  • Convertibility (Conversions): the explicit T(x) form, which is looser — it permits conversions assignment forbids, e.g. Fahrenheit(c) between two named types sharing an underlying type, or []byte("hello").

The full development of these rules lives in Type Identity and Assignability; this note states them so the rest of Section 11 can refer back.

Methods and interfaces — behavior without inheritance

A defined type may have methods declared on it; the set of methods callable on a value is its method set (see Method Sets). An interface type defines a type set — the set of all types that implement its methods — and “a type T implements an interface I if T is not an interface and is an element of the type set of I (Implementing an interface). Satisfaction is structural and implicit: no declaration ties a concrete type to an interface; having the methods is sufficient. This is developed in Interfaces in Go; the runtime representation of the resulting two-word interface value is in Interface Internals.

Generics — the 1.18 extension

Go 1.18 (March 2022) extended the type system with type parameters: functions and types can be parameterized over types, constrained by interfaces used as type sets (Go 1.18 release notes). The ~T element and union elements (A | B) let an interface name a set of underlying types rather than only a set of methods. This is the one large addition since Go 1.0; it is covered in Generics and Type Parameters, Type Sets and Constraints, and Type Inference in Go. Type-argument inference itself was meaningfully broadened in Go 1.21 — a generic function may now be passed uninstantiated or partially instantiated as an argument, so type parameters from different functions can be inferred together, which Go 1.18 disallowed (Go blog: type inference); the mechanics live in Type Inference in Go.

As of the current stable release, Go 1.26 (1.26.3, May 2026), the type system has seen only two narrowly scoped language changes since generics: the built-in new now accepts an arbitrary expression as its operand (new(yearsSince(born))) so the freshly allocated variable is initialized to that value rather than the zero value, and the long-standing restriction that a generic type may not refer to itself in its own type-parameter list has been liftedtype Adder[A Adder[A]] interface{ Add(A) A } is now legal, which the release notes observe also “simplifies the spec rules for type parameters ever so slightly” (Go 1.26 release notes, Changes to the language; Go 1.26 is released). Neither change touches identity, assignability, or convertibility. Note also that the spec itself dropped the “core type” concept in Go 1.25 (August 2025) — see Goodbye core types. “Core type” was an abstraction introduced alongside generics in 1.18 to handle operands whose type is a type parameter: for a non-type-parameter it was just the underlying type, and for a type parameter it was the single shared underlying type of every type in the parameter’s type set. The 1.25 cleanup reverted much of the affected prose to its pre-generics form and re-derived the generic cases directly from type sets, a change the Go team states is 100% backward-compatible. This note never relied on “core type” terminology, so the identity/assignability/convertibility rules above stand unchanged.

Code Examples

Distinct types, shared underlying type

type Celsius float64                 // 1
type Fahrenheit float64              // 2
 
func CToF(c Celsius) Fahrenheit {    // 3
	return Fahrenheit(c*9/5 + 32)    // 4
}
 
var c Celsius = 100
var f Fahrenheit = c                 // 5  COMPILE ERROR
var f2 Fahrenheit = Fahrenheit(c)    // 6  OK
var x float64 = c                    // 7  COMPILE ERROR
var x2 float64 = float64(c)          // 8  OK

Line 1–2: two defined types, both with underlying type float64, both distinct from each other and from float64. Line 3: the function signature uses the named types so the compiler can catch unit mix-ups. Line 4: the arithmetic produces a Celsius (operations are inherited from the underlying type), so it must be converted to Fahrenheit for the return. Line 5: direct assignment fails — Celsius and Fahrenheit are both named and not identical; assignability is denied. Line 6: explicit conversion succeeds — they share an underlying type. Line 7: Celsius to float64 also fails assignment (both named, distinct). Line 8: but converts fine. This is the nominal type system doing its job: distinctness is not an accident, it is the safety feature.

Named type vs unnamed literal

type IntSlice []int                  // defined type; underlying type []int
 
func sum(s []int) int { /* ... */ }   // takes the literal type
 
var is IntSlice = []int{1, 2, 3}      // 1  OK: []int is unnamed
var raw []int = is                    // 2  OK: still one side unnamed
total := sum(is)                      // 3  OK: IntSlice assignable to []int param

Line 1: []int{...} has the unnamed type []int; assigning it to an IntSlice variable is allowed because identical underlying types + one side (the literal) is not named. Line 2: the reverse also works for the same reason. Line 3: passing an IntSlice to a func([]int) parameter is an assignment, and it is allowed — one side is the unnamed literal []int. Contrast with the Celsius/Fahrenheit case where both sides were named: that is the entire difference, and it is exactly the “at least one not named” clause.

Behavior, not inheritance

type Animal interface{ Sound() string }     // 1
 
type Dog struct{ Name string }
func (d Dog) Sound() string { return "woof" }  // 2
 
type Cat struct{ Name string }
func (c Cat) Sound() string { return "meow" }  // 3
 
func describe(a Animal) string {              // 4
	return a.Sound()
}
 
// describe(Dog{"Rex"}) and describe(Cat{"Tom"}) both work — 5

Line 1: an interface declares behavior. Lines 2–3: Dog and Cat are unrelated structs; neither says implements Animal. Line 4: describe is polymorphic over anything with a Sound() string method. Line 5: both satisfy Animal structurally — the compiler checks the method set at the call site. There is no class hierarchy: Dog and Cat share no common ancestor type, only a common interface. This is Go’s substitute for subtype polymorphism, and it is covered fully in Interfaces in Go.

Common Misunderstandings

“A defined type inherits the original’s methods.” It does not. type MyMutex sync.Mutex produces a type with an empty method set — the spec example is explicit. To carry the methods, embed the type (type MyMutex struct{ sync.Mutex }), which promotes them; embedding is a distinct mechanism, see Struct Embedding.

“An alias creates a new type.” It does not. type A = T (Go 1.9+) makes A and T the same type — identical method sets, freely interchangeable, no conversion. A definition type A T makes a new distinct type. The single = is the whole difference; conflating the two is a common source of “why won’t this assign” confusion.

“Two structs with the same fields are the same type.” Only if at least one is an unnamed struct literal. Two defined struct types with identical fields are distinct and need conversion — and the conversion is allowed precisely because the underlying type literals are identical.

“Go’s type system is structural.” Only for interfaces. For concrete types Go is nominal — identity tracks the declaration, not the shape. Saying “Go is structurally typed” without the interface qualifier is wrong.

“Conversion is the same as assignment.” Conversion (T(x)) is strictly looser: it permits things assignment forbids (named-to-named with shared underlying type, []bytestring, numeric narrowing). Assignment is the implicit, stricter relation. See Type Identity and Assignability.

int and int32 are interchangeable.” No — they are distinct predeclared types; int is platform-sized (32 or 64 bits) while int32 is fixed. Mixing them needs a conversion. See Word Size and Architecture Portability.

Alternatives — How Go’s Choices Compare

Go’s type system is a deliberate set of trade-offs against the languages it draws from. Versus class-based OOP (Java, C++): Go drops inheritance entirely, replacing it with interfaces + embedding. The win is no fragile base class and no diamond problem; the cost is no implementation inheritance, so shared behavior is either embedded or duplicated. Versus structural typing throughout (TypeScript): Go is structural only for interfaces; concrete types are nominal, which catches more bugs (Celsius vs Fahrenheit) at the price of more explicit conversions. Versus Hindley-Milner inference (Haskell, ML): Go’s inference is local and modest — it infers the type of := declarations and, since 1.18, type arguments — but never infers function parameter or return types; signatures are always explicit, which keeps the type system simple and error messages local. Versus dynamic typing (Python): Go is fully static; the empty interface any plus reflection (Reflection in Go) provides an escape hatch for the rare dynamic case, but it is opt-in and visible.

The recurring design principle: make the common case statically checked and explicit, and make the dynamic escape hatch loud. That is why any stands out in a signature and why every cross-type move requires a visible conversion.

Production Notes

The practical payoff of the nominal half is domain modeling: defining type UserID string, type Cents int64, type Email string instead of passing bare string/int64 makes whole categories of argument-swap bugs unreachable — the compiler rejects SendEmail(userID) if SendEmail wants an Email. This is the cheapest static safety Go offers and costs only a type line plus occasional conversions. Standard-library practice models it: time.Duration is a defined int64, os.FileMode a defined uint32.

The practical payoff of the structural half is decoupling: a package can accept an io.Reader without the caller’s type ever importing or naming io.Reader at definition time — satisfaction is checked where the value is used. This is why Go packages compose without a web of implements dependencies, and why the small standard interfaces (io.Reader, io.Writer, fmt.Stringer, error) are so pervasive.

A common production guard is the interface satisfaction assertion var _ SomeInterface = (*MyType)(nil) — a zero-cost compile-time check, recommended in Effective Go, that fails the build if *MyType ever stops satisfying the interface. It is the idiomatic way to pin a structural contract that would otherwise only be checked at a distant call site.

See Also