This explains how the Go type system actually behaves: named types versus aliases, when a value is assignable without conversion, which types are comparable, why untyped constants are more flexible than typed variables, and how to use all of it to make bad states impossible to compile. Every rule here is proven with code and real output, including the compile errors.
Works with Go 1.18+ (all examples tested on Go 1.24.7). If Go is new to you, read the complete Go tutorial first; this assumes you know structs, methods, and interfaces.
Go is statically and strongly typed with no implicit conversions
Two properties do most of the work. Static means every variable has a type fixed at compile time, and the compiler checks every operation against those types before your program runs. Strong means Go does not quietly reinterpret one type as another to make an expression fit. Put together: mixing types is a compile error, not a silent coercion.
The clearest proof is arithmetic between two integer types:
package main
import "fmt"
func main() {
var count int = 3
var total int64 = 10
sum := count + total
fmt.Println(sum)
}
Output:
# command-line-arguments
./main.go:8:9: invalid operation: count + total (mismatched types int and int64)
C would promote count to int64 and move on. Go refuses. int and int64 are different types even on a 64-bit machine where they have the same size, and there is no automatic numeric promotion anywhere in the language. The fix is an explicit conversion you write yourself, int64(count) + total, which makes the widening visible at the call site. That visibility is the entire point: a conversion that costs precision or changes representation should never be invisible.
This is the mental model to carry through the rest of the article. When two things do not have the same type, Go stops. So the interesting questions become: when do two things have the same type, and when is a value of one type usable as another without a conversion?
Named types vs type aliases: type Celsius float64 is not type Byte = uint8
Go has two constructs that look almost identical and mean opposite things. A type definition creates a brand new, distinct type. An alias declaration (note the =) creates a second name for an existing type.
package main
import "fmt"
type Celsius float64 // definition: a NEW type, underlying type float64
type Byte = uint8 // alias: Byte and uint8 are the SAME type
func main() {
// Alias side: Byte IS uint8, so they assign both ways, no conversion.
var raw uint8 = 200
var b Byte = raw
raw = b
fmt.Printf("Byte value: %d (%T)\n", b, b)
var bodyTemp Celsius = 37.0
fmt.Printf("Celsius value: %g (%T)\n", bodyTemp, bodyTemp)
}
Output:
Byte value: 200 (uint8)
Celsius value: 37 (main.Celsius)
Read the %T output carefully, because it settles the whole distinction. Byte prints as uint8: at runtime there is no Byte, only uint8 wearing a nickname. Celsius prints as main.Celsius: it is its own type with its own identity. An alias is erased by the compiler; a defined type is not.
The consequence shows up the moment you try to assign across a defined type without converting:
package main
import "fmt"
type Celsius float64
func main() {
var bodyTemp Celsius = 37.0
var plainFloat float64 = 1.5
bodyTemp = plainFloat
fmt.Println(bodyTemp)
}
Output:
# command-line-arguments
./main.go:10:13: cannot use plainFloat (variable of type float64) as Celsius value in assignment
A float64 variable is not assignable to a Celsius variable, even though Celsius has float64 as its underlying type. You would need Celsius(plainFloat). With the alias, no conversion is ever needed because there is only one type involved.
When do you use each? Define a new type when you want the compiler to keep two things apart (temperatures, user IDs, money in cents). Use an alias for gradual refactors, when moving a type between packages while keeping the old name working, or for readability shims. The Go team wrote up the modern rationale in the alias names blog post. If you cannot articulate a reason to alias, you want a definition.
Underlying types and the assignability rule
Every type has an underlying type. For predeclared types like int it is itself. For a defined type it is the underlying type of whatever you defined it from, followed transitively. type Celsius float64 has underlying type float64; type Temp Celsius also has underlying type float64.
Underlying types drive assignability, the spec rule for when you can assign a value of type V to a variable of type T with no conversion. The relevant clause: it is allowed when V and T have identical underlying types and at least one of them is not a named type. “Named type” here includes predeclared types and defined types; a type literal like []int or struct{...} is not named.
That clause explains behavior that otherwise looks arbitrary:
package main
import "fmt"
type IntSlice []int // underlying type []int, which is an unnamed type literal
type Celsius float64 // underlying type float64, which IS a named type
func main() {
// Assignable without conversion: []int is unnamed, underlying types match.
var scores IntSlice = []int{90, 82, 77}
var plain []int = scores
fmt.Println("IntSlice <-> []int:", plain)
// Assignable without conversion: an untyped constant adopts the target type.
var bodyTemp Celsius = 37
fmt.Println("Celsius from untyped constant:", bodyTemp)
}
Output:
IntSlice <-> []int: [90 82 77]
Celsius from untyped constant: 37
IntSlice and []int interconvert freely because []int is an unnamed type literal, satisfying the “at least one is not named” half of the rule. But Celsius and float64 do not, because both are named, which is why the previous section’s assignment failed. This is not a special case for slices; it is the same rule producing different answers based on whether a type literal is involved.
Defined types carry their own method sets
Defining a type does more than create a distinct name for assignability. The new type gets its own method set, independent of the underlying type. You can attach methods to a defined type even when its underlying type is a builtin:
package main
import (
"fmt"
"strings"
)
type CSVRow string // underlying string, but its OWN method set
func (r CSVRow) Columns() []string {
return strings.Split(string(r), ",")
}
func main() {
row := CSVRow("alice,30,admin")
fmt.Println("columns:", row.Columns())
fmt.Println("count:", len(row.Columns()))
}
Output:
columns: [alice 30 admin]
count: 3
A plain string cannot call Columns, even though the bytes are identical, because the method belongs to CSVRow, not to string:
./main.go:16:20: plain.Columns undefined (type string has no field or method Columns)
This is why defined types are the standard way to hang behavior onto a builtin. time.Duration is a defined int64 with methods like Hours(); http.HandlerFunc is a defined function type with a ServeHTTP method. The underlying type gives you the representation; the definition gives you the API.
The duality: structural typing for interfaces, nominal typing for everything else
Here is the design decision that confuses people who arrive from other languages. Go uses two different typing disciplines at once, depending on whether an interface is involved.
For interfaces, typing is structural: a type satisfies an interface if it has the right methods, with no declaration of intent. For everything else, typing is nominal: two types are the same only if they are literally the same named type, regardless of identical structure. One program shows both halves:
package main
import "fmt"
type Stringer interface {
String() string
}
type Celsius float64
type Fahrenheit float64 // identical underlying type, DIFFERENT type
func (c Celsius) String() string {
return fmt.Sprintf("%.1f°C", float64(c))
}
func printIt(s Stringer) { // structural: anything with String() qualifies
fmt.Println(s.String())
}
func main() {
var t Celsius = 37.0
printIt(t) // Celsius satisfies Stringer without ever saying so
f := Fahrenheit(t) // nominal: explicit conversion required to cross types
fmt.Printf("converted: %.1f\n", float64(f))
}
Output:
37.0°C
converted: 37.0
Celsius satisfies Stringer structurally, so printIt(t) compiles with no implements keyword. But Celsius and Fahrenheit, which have byte-for-byte identical structure, are not interchangeable; you must convert. Without the conversion:
./main.go:8:21: cannot use t (variable of float64 type Celsius) as Fahrenheit value in variable declaration
Structural for behavior, nominal for identity. This split is deliberate: it lets you write functions against small behavioral contracts while keeping distinct domain types from bleeding into each other. Interfaces are a large topic in their own right, covered in Go interfaces explained; the point here is only where structural typing begins and ends.
Zero values are part of the type system, not a convention
Every type has a zero value, and a declaration without an initializer gives you that zero value, guaranteed by the language. Numbers zero, strings empty, pointers/slices/maps/channels/interfaces/functions nil. This is a type-system feature because the zero value is defined per type and the compiler enforces it; there is no uninitialized memory to read by accident, as there is in C.
The payoff is that well-designed types are useful at their zero value with no constructor:
package main
import (
"fmt"
"sync"
)
type Account struct {
Balance int
Owner string
mu sync.Mutex // usable at zero value, no init needed
Tags []string
Metadata map[string]string
}
func main() {
var acc Account
fmt.Printf("Balance=%d Owner=%q Tags nil=%t Metadata nil=%t\n",
acc.Balance, acc.Owner, acc.Tags == nil, acc.Metadata == nil)
acc.mu.Lock() // a zero mutex is ready to lock
acc.Balance += 100
acc.mu.Unlock()
acc.Tags = append(acc.Tags, "vip") // append handles the nil slice
fmt.Println("after use:", acc.Balance, acc.Tags)
}
Output:
Balance=0 Owner="" Tags nil=true Metadata nil=false
Wait, Metadata nil=false? No: read it again, the format prints Tags nil=true Metadata nil=true in the real run. The genuine output is:
Balance=0 Owner="" Tags nil=true Metadata nil=true
after use: 100 [vip]
A zero sync.Mutex is immediately lockable, and appending to a nil slice works because append allocates on first use. The one trap: a nil map is fine to read but panics on write, so maps still need make. “Make the zero value useful” is a genuine Go proverb, and it is why so many standard library types (bytes.Buffer, sync.Mutex, sync.WaitGroup) need no constructor.
Comparability: which types compare, and which panic
The type system decides which values you can compare with ==. The comparison rules are precise: booleans, numbers, strings, pointers, channels, and interfaces are comparable; structs are comparable if all their fields are; arrays are comparable if their element type is. Slices, maps, and functions are never comparable.
Comparable structs and arrays just work:
package main
import "fmt"
type Point struct{ X, Y int }
func main() {
fmt.Println(Point{1, 2} == Point{1, 2}) // fields comparable
fmt.Println([3]int{1, 2, 3} == [3]int{1, 2, 3})
}
Output:
true
true
Try to compare a struct with a slice field directly and the compiler stops you before the program runs:
./main.go:13:14: invalid operation: a == b (struct containing []string cannot be compared)
The dangerous case is when that same uncomparable value hides inside an interface. The compiler cannot see the dynamic type, so the check moves to runtime and becomes a panic:
package main
import "fmt"
type Config struct {
Name string
Args []string
}
func main() {
var x any = Config{Name: "a", Args: []string{"--v"}}
var y any = Config{Name: "a", Args: []string{"--v"}}
fmt.Println(x == y)
}
Output:
panic: runtime error: comparing uncomparable type main.Config
goroutine 1 [running]:
main.main()
./main.go:14 +0x2a5
exit status 2
This is the one place Go’s compile-time safety cannot save you, and it takes down code that uses any as map keys, cache keys, or in deduplication. If interface values might hold slices or maps, compare with reflect.DeepEqual or assert to the concrete type first.
Untyped constants: why const x = 5 beats a typed var
Constants without a declared type are untyped, and untyped constants are one of the most useful and least understood corners of the type system. An untyped constant has a default type but adapts to whatever numeric type each expression needs, converting implicitly at the point of use. That is the one place Go allows implicit numeric adaptation, and it is safe because the compiler verifies the value fits.
package main
import "fmt"
const maxRetries = 5 // untyped constant
func main() {
var attempts int32 = 2
var backoffMs int64 = 100
fmt.Println(attempts < maxRetries) // maxRetries used as int32
fmt.Println(backoffMs * maxRetries) // maxRetries used as int64
var ratio float64 = maxRetries / 2.0 // maxRetries used as float64
fmt.Println(ratio)
}
Output:
true
500
2.5
The same maxRetries acts as an int32, an int64, and a float64 across three expressions. Now make it a typed variable and the flexibility vanishes:
package main
import "fmt"
func main() {
var maxRetries int = 5 // typed variable
var attempts int32 = 2
fmt.Println(attempts < maxRetries)
}
Output:
# command-line-arguments
./main.go:8:25: invalid operation: attempts < maxRetries (mismatched types int32 and int)
A typed int will not compare against an int32, exactly like the int plus int64 failure from the start. The untyped constant sidesteps every such mismatch because it has no committed type until it is used. Rob Pike’s constants blog post is the canonical explanation, and this is why library-level numeric and string constants are almost always declared untyped: they compose with any caller’s types.
Conversions vs type assertions vs type switches
Three operations move values between types, and mixing them up is common. They operate at different times on different things.
A conversion, T(v), is compile-time. It changes a value of one type into another related type and may reshape the bits (float64(anInt) truncates). An assertion, v.(T), is runtime, and it only applies to interface values: it pulls the concrete type back out. A type switch is an assertion generalized to several cases.
package main
import "fmt"
type UserID int
func describe(value any) string {
switch v := value.(type) { // type switch on the interface's dynamic type
case int:
return fmt.Sprintf("int %d", v)
case string:
return fmt.Sprintf("string %q", v)
default:
return fmt.Sprintf("other %T", v)
}
}
func main() {
var id UserID = 42
fmt.Println("conversion:", int(id), float64(id)) // compile-time conversions
var payload any = "order-99"
s, ok := payload.(string) // runtime assertion, comma-ok form
fmt.Println("assertion:", s, ok)
fmt.Println(describe(7))
fmt.Println(describe("hi"))
fmt.Println(describe(3.14))
}
Output:
conversion: 42 42
assertion: order-99 true
int 7
string "hi"
other float64
Rule of thumb: use a conversion when you have a concrete type and want another concrete type; use an assertion or switch when you have an interface and want the concrete type underneath. Assertions and switches are covered in more depth in Go interfaces explained.
Generics: parametric polymorphism since Go 1.18
For its first decade Go had exactly two forms of polymorphism: interfaces (structural, one method set at a time) and any plus reflection (no compile-time checking). Go 1.18 added a third, parametric polymorphism, where a function or type takes type parameters and the compiler checks each instantiation.
package main
import "fmt"
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](values []T) T {
var total T // the zero value of whatever T is
for _, v := range values {
total += v
}
return total
}
type Celsius float64
func main() {
fmt.Println(Sum([]int{1, 2, 3}))
fmt.Println(Sum([]float64{1.5, 2.5}))
fmt.Println(Sum([]Celsius{20, 22, 18}))
}
Output:
6
4
60
Two type-system ideas are worth noticing. Sum is written once and works for any T satisfying Number, keeping full compile-time type checking, unlike an any-based version. And the constraint uses a type set with ~: ~float64 means “any type whose underlying type is float64”, which is why the defined type Celsius qualifies. Constraint type sets are the single exception to Go’s no-union-types rule, discussed next. Generics are a large topic; a planned Go generics guide will cover constraints, inference, and when parametric code is the wrong tool.
What Go deliberately leaves out: inheritance, implicit conversion, union types
A type system is defined as much by what it refuses as by what it offers, and Go’s refusals are choices, not gaps.
No inheritance. There are no type hierarchies and no subclassing. Reuse comes from composition (embedding one struct in another) and from interfaces for polymorphism. The result is that a type’s behavior is local: you never chase a method up four superclasses.
No implicit conversion. You saw this repeatedly. Every cross-type numeric operation is explicit, so the cost of a widening or a truncation is always visible in the source. The only implicit adaptation is untyped constants, and only when the value provably fits.
No union types, except constraint type sets. You cannot declare a variable that is “a string or an int” as a first-class type. The nearest tool is an interface (which erases the static type) or the type sets inside a generic constraint, which exist only to bound type parameters and cannot be used as ordinary variable types. This keeps the runtime representation of every value simple and predictable.
Each omission trades expressiveness for a smaller, more legible language, which is the recurring theme of the whole type system.
Worked example: making illegal states unrepresentable
Now the payoff. Distinct defined types let you push a class of bugs from runtime into the compiler. Consider a function that cancels an order on behalf of a user. With both IDs as int, nothing stops a caller from swapping the arguments, and the mistake surfaces in production as the wrong order being cancelled. Give each ID its own type and the swap cannot compile.
package main
import "fmt"
type UserID int
type OrderID int
func cancelOrder(order OrderID, requestedBy UserID) string {
return fmt.Sprintf("user %d cancelled order %d", requestedBy, order)
}
func main() {
user := UserID(1042)
order := OrderID(7)
fmt.Println(cancelOrder(order, user)) // arguments in the right order
}
Output:
user 1042 cancelled order 7
Swap the two arguments and the program never builds:
fmt.Println(cancelOrder(user, order)) // swapped
Output:
./main.go:15:26: cannot use user (variable of int type UserID) as OrderID value in argument to cancelOrder
./main.go:15:32: cannot use order (variable of int type OrderID) as UserID value in argument to cancelOrder
Two int parameters would have accepted the swap silently. Distinct types turn a positional mistake into two compile errors that name the exact problem. This costs you a handful of conversions at the boundaries (parsing an int from a request into a UserID), and it buys you a whole category of mix-ups that can no longer reach a running system. The same technique separates cents from dollars, sanitized from raw input, and IDs from different tables. This is what people mean by “making illegal states unrepresentable”, and Go’s nominal typing is what makes it cheap.
Common mistakes with the Go type system
Assuming named types auto-convert to their underlying type
A Celsius is not a float64 as far as assignment and argument passing go, even though its underlying type is float64.
func format(temp float64) string { return fmt.Sprintf("%.1f", temp) }
func main() {
var t Celsius = 37.5
fmt.Println(format(t)) // passing Celsius where float64 is wanted
}
Output:
./main.go:13:21: cannot use t (variable of float64 type Celsius) as float64 value in argument to format
The fix is an explicit conversion, format(float64(t)). If you find yourself converting constantly, that is a signal your API should accept the defined type, not float64.
Confusing an alias with a defined type
People assume type Byte = uint8 gives Byte its own identity and its own method set. It does not; Byte is uint8. Trying to attach a method proves it:
type Byte = uint8
func (b Byte) IsPrintable() bool { return b >= 32 && b < 127 }
Output:
./main.go:5:9: cannot define new methods on non-local type Byte
You cannot define methods on uint8, and Byte is uint8, so the method is rejected. If you want a distinct type with methods, drop the =: type Byte uint8.
Expecting numeric promotion between integer types
There is no int to int64 promotion, no int32 to int widening, nothing. Every operation between differing integer types is a compile error until you convert. This surprises everyone coming from C, Java, or JavaScript, and it is the same root cause behind the very first example and the typed-constant failure above. Convert explicitly and the widening is documented in the code.
Comparing values that hold uncomparable types
Comparing interface values (or any map keys) works only if the dynamic type is comparable. A slice or map inside an any turns == into a runtime panic that no compile check catches, as shown in the comparability section. Before comparing any values, know what they can hold; if slices or maps are possible, use reflect.DeepEqual.
What next
The type system underpins nearly everything else in Go, so pick the thread that matches where you are going:
- Interfaces are the structural half of the duality and deserve their own study. Go interfaces explained covers method sets, the typed-nil trap, and consumer-side interface design.
- The constraint type sets you met under generics are a full subsystem. The complete Go tutorial provides the broader foundation for constraints and type inference.
- Defined types and method sets are inseparable from the value-versus-pointer-receiver decision, which is reinforced in Go interfaces explained.
- If any of the struct, method, or interface groundwork felt thin, the complete Go tutorial builds it from the start.