The vending machine problem
You are writing a service that accepts configuration from a JSON file. The file contains a port number, a timeout value, and a maximum retry count. All three are integers. You write a function that takes an int and starts listening on a network socket. Somewhere down the line, a teammate passes the retry count instead of the port. The program compiles. It runs. It binds to port 4 and crashes with a permission error.
Go's type system caught the logic error at runtime because it only saw int everywhere. The language gives you two tools to stop this before the binary even builds. One creates a completely new type with its own identity. The other creates a transparent synonym. Knowing which one to reach for changes how your code reads, how the compiler validates it, and how you structure your packages.
Stamps and stickers
A type declaration (type X Y) forges a new type. The underlying storage matches Y, but the compiler treats X as a separate entity. Think of it like minting a new coin using the exact same metal weight and purity, but stamping a different face on it. The vending machine only accepts the exact stamp it was programmed for.
A type alias (type X = Y) puts a sticker on the existing type. The coin is identical. The machine does not care about the sticker. X and Y are interchangeable in every context, including function signatures, interface satisfaction, and generic type parameters.
Go introduced type aliases in version 1.9 to support refactoring and generic shorthands without breaking existing code. Before that, you had to rewrite every call site when renaming a type. Aliases let you migrate gradually while keeping the compiler happy.
The compiler's type identity check
The Go compiler tracks type identity, not just underlying representation. When you write type Port int, the compiler registers Port as a distinct type with an underlying type of int. Assignment, function arguments, and return values require exact type matches unless you perform an explicit conversion.
package main
import "fmt"
// Port is a distinct type with int as its underlying storage
type Port int
// AliasPort is a transparent synonym for int
type AliasPort = int
func main() {
// p is strictly a Port. The compiler tracks its identity separately.
var p Port = 8080
var raw int = 9090
var a AliasPort = 3000
// The compiler rejects this because Port and int are different identities.
// p = raw // cannot use raw (type int) as type Port in assignment
// The compiler accepts this because AliasPort and int are identical.
a = raw
fmt.Println(p, raw, a)
}
The compiler does not care that Port and int share the same memory layout. It cares about the name registered in the type table. This strictness is intentional. It prevents accidental mixing of domain concepts that happen to share a primitive representation.
Aliases skip the identity check entirely. The compiler substitutes AliasPort with int during type checking. You can pass an AliasPort to a function expecting int, assign an int to an AliasPort, and use them in the same slice without conversions. The type system treats them as the same thing.
Type identity matters when you want the compiler to enforce boundaries. It matters less when you only want a clearer name for documentation or tooling.
Attaching behavior to new types
The real power of type declarations shows up when you attach methods. Go allows you to define methods on a type only if the type is defined in the same package. You cannot add methods to int, string, or []byte directly because those types live in the built-in universe. You create a new type with that underlying representation, then attach the behavior you need.
package main
import "fmt"
// Port represents a network port number with validation logic
type Port int
// Validate checks if the port falls within the standard range
// Port.Validate ensures callers cannot pass invalid numbers
func (p Port) Validate() bool {
return p >= 1 && p <= 65535
}
func main() {
// p carries the Port identity, so it can call Validate
p := Port(443)
fmt.Println(p.Validate())
// raw is a plain int. It has no Validate method.
raw := 443
// raw.Validate() // compiler error: raw.Validate undefined
}
The receiver name follows Go convention: one or two letters matching the type, usually p for Port. The method set belongs to Port, not to int. You can pass p to any function that accepts Port, and you can use it in interfaces that require a Validate() bool method.
Aliases cannot have their own methods. Since AliasPort is just int, any method you try to attach would actually be attaching to int, which the compiler forbids. If you need custom behavior, you must use a type declaration. If you only need a label, an alias keeps the method set identical to the original type.
Go's method attachment rules also tie into the "accept interfaces, return structs" convention. You declare the concrete type with methods, then expose an interface that describes the behavior. Callers depend on the interface. The alias approach bypasses this pattern entirely because it offers no new identity to bind to.
Where the type system catches you
Strict type identity creates friction when you move data between domains. You cannot pass a Port to a function expecting int. You must convert explicitly. The conversion syntax is Type(value), and it only works between types with compatible underlying representations.
package main
import "fmt"
type Port int
func openSocket(raw int) {
fmt.Println("binding to", raw)
}
func main() {
p := Port(8080)
// Explicit conversion tells the compiler you understand the risk
openSocket(int(p))
// The compiler rejects implicit mixing
// openSocket(p) // cannot use p (type Port) as type int in argument
}
The compiler error reads cannot use p (type Port) as type int in argument. It does not guess your intent. You write the conversion, and the compiler verifies that the underlying types match. If you try to convert a Port to a string, the compiler rejects it with cannot convert p (type Port) to type string. The underlying types differ, so the conversion is illegal.
Aliases avoid this friction entirely. They are transparent to the compiler, so you never need conversion syntax. This transparency becomes a double-edged sword when you refactor. If you rename type AliasPort = int to type AliasPort = uint16, every call site that previously accepted int now accepts uint16 without warning. The compiler treats them as identical, so it does not flag the change. Type declarations catch the mismatch because the identity stays fixed even if you change the underlying type later.
Generics interact with both forms differently. A type declaration creates a new type parameter identity. An alias preserves the original identity, which means generic constraints resolve against the underlying type. If you write type List[T any] = []T, the compiler treats List[int] exactly as []int. If you write type List[T any] []T, List[int] is a distinct type that requires explicit conversion to []int. Pick the form that matches your API contract.
Goroutine leaks and channel operations follow the same rules. A chan Port only accepts Port values. A chan AliasPort accepts int values. The type system enforces the boundary at compile time, which saves you from runtime panics when you accidentally send the wrong shape down a channel.
Trust the type system. Write the conversion when you mean it. Let the compiler reject accidental mixing.
When to pick which
Use a type declaration when you need the compiler to enforce domain boundaries between values that share the same underlying storage. Use a type declaration when you want to attach methods or satisfy interfaces with custom behavior. Use a type declaration when you are building a public API and want to prevent callers from passing raw primitives. Use a type alias when you only need a shorter or more descriptive name for an existing type without changing its identity. Use a type alias when you are refactoring legacy code and want to migrate gradually without breaking call sites. Use a type alias when you need a readable shorthand for complex generic type parameters. Use plain sequential code when you don't need type safety or readability improvements: the simplest thing that works is usually the right thing.