The checkout service problem
You are building a checkout service. Prices are stored as float64. You want to calculate tax by writing price.Tax() instead of calling a standalone function. You write func (p float64) Tax() float64. The compiler rejects the code. Go refuses to let you attach methods to built-in types like int, string, bool, or float64. The language protects the core types from being modified by third-party code. To get behavior attached to a primitive, you define a new type that wraps the primitive and attach the methods there.
Type definitions create boundaries
Go distinguishes between a type definition and a type alias. A definition creates a distinct type in the type system. An alias is just a nickname. When you write type MyInt int, you are telling the compiler, "I want a type called MyInt that behaves like an int internally, but I want to treat it as something different." This new type gets its own identity. You can attach methods to MyInt. You cannot attach methods to int because int belongs to the universe package, and you cannot modify the universe package.
The standard library uses this pattern everywhere. time.Duration is an int64 with methods like Hours() and String(). url.Values is a map[string][]string with methods for parsing query strings. http.Header is a map[string][]string with methods for case-insensitive access. These types look like primitives or maps, but they carry domain-specific behavior.
Type definitions create boundaries. Aliases create shortcuts.
Minimal example
Here is the simplest pattern: define a type based on a primitive, attach a method, and call it.
package main
import "fmt"
// Celsius is a float64 with domain-specific behavior.
// This creates a new type, distinct from float64.
type Celsius float64
// ToFahrenheit converts Celsius to Fahrenheit.
// The receiver is Celsius, not float64.
func (c Celsius) ToFahrenheit() Celsius {
// Conversion formula: (C * 9/5) + 32
return Celsius(c*9/5 + 32)
}
func main() {
// Create a Celsius value.
temp := Celsius(100)
// Call the method on the custom type.
fmt.Println(temp.ToFahrenheit()) // Output: 212
}
The compiler enforces the boundary. Conversions are the gates.
Walkthrough
At compile time, the compiler sees Celsius and float64 as unrelated types. You cannot assign a float64 to a Celsius without an explicit conversion. This safety check prevents accidental mixing of units. If you write var c Celsius = 100.0, the compiler accepts it because 100.0 is an untyped constant that can be converted to Celsius. If you write var f float64 = 100.0; var c Celsius = f, the compiler rejects it with cannot use f (type float64) as type Celsius in assignment. You must write Celsius(f).
At runtime, Celsius has the exact same memory layout as float64. There is zero overhead. The method call temp.ToFahrenheit() passes the value of temp to the function. Since Celsius is a value type, the receiver is copied. The method returns a new Celsius value. The type system enforces that you return Celsius, not float64, keeping the domain logic contained.
Conversions between types with the same underlying type are zero-cost. The compiler optimizes Celsius(f) away. It is a type check, not a data copy. The generated machine code treats Celsius and float64 identically.
Realistic example
Real code often needs validation or custom formatting. This example defines a Status type based on string and adds a Valid method to enforce domain rules. It also implements the String() method, which controls how the type prints in fmt functions.
package main
import "fmt"
// Status represents a request state.
// Defining a type prevents passing random strings.
type Status string
// Valid checks if the status is one of the allowed values.
// This centralizes validation logic.
func (s Status) Valid() bool {
switch s {
case "pending", "active", "closed":
return true
}
return false
}
// String implements fmt.Stringer.
// This controls how Status prints in fmt functions.
func (s Status) String() string {
if !s.Valid() {
return "unknown_status"
}
return string(s)
}
func main() {
// Create a status value.
s := Status("active")
// Validate before use.
if !s.Valid() {
fmt.Println("Invalid status")
return
}
// String() is called automatically by fmt.Println.
fmt.Println("Current:", s) // Output: Current: active
// Invalid status falls back to safe representation.
bad := Status("deleted")
fmt.Println("Bad:", bad) // Output: Bad: unknown_status
}
Validation lives on the type. Random strings stay out.
Modifying values and pointers
Methods can read the receiver or modify it. If you need to modify the value, you must use a pointer receiver. A value receiver gets a copy of the data. Changes to the copy do not affect the original. A pointer receiver gets the address of the data. Changes to the pointed-to value persist.
package main
import "fmt"
// Counter is an int with increment logic.
type Counter int
// Increment modifies the receiver.
// A pointer receiver is required to change the underlying value.
func (c *Counter) Increment() {
*c++
}
// Double returns a new value.
// A value receiver is sufficient since we don't modify c.
func (c Counter) Double() Counter {
return c * 2
}
func main() {
var count Counter = 5
// Increment modifies the original value.
count.Increment()
fmt.Println(count) // Output: 6
// Double returns a new value without modifying count.
fmt.Println(count.Double()) // Output: 12
fmt.Println(count) // Output: 6
}
Pointer receivers allow mutation. Value receivers preserve immutability.
Pitfalls and conventions
The alias trap is the most common mistake. Writing type MyInt = int creates an alias, not a definition. MyInt and int are identical. You cannot add methods to MyInt because it is just int. The compiler rejects func (m MyInt) Double() with cannot define method on non-local type int. You must use type MyInt int to create a distinct type.
Conversions are explicit. Forgetting a conversion causes a compile error. The compiler complains with cannot use x (type MyInt) as type int in argument if you pass a custom type to a function expecting the underlying type. This friction is intentional. It forces you to acknowledge the type boundary.
Receiver naming follows a convention. The receiver name is usually one or two letters matching the type. Write (s Status) or (c Celsius). Do not write (this Status) or (self Status). Those are Python and Java conventions. Go code uses short names. The gofmt tool does not enforce receiver names, but the community expects them. Most editors run gofmt on save. Trust gofmt. Argue logic, not formatting.
Error handling is verbose by design. When methods return errors, check them immediately. The pattern if err != nil { return err } is standard. It makes the unhappy path visible. Do not hide errors inside methods. Propagate them to the caller.
Decision matrix
Use a defined type like type ID int when you need to attach behavior or enforce type safety between similar primitives. Use a type alias like type ID = int when you only need a nickname for readability and want the type to remain compatible with the underlying type without conversions. Use a struct when the value needs multiple fields or metadata alongside the primitive. Use a plain function when the logic does not belong to the value itself or when you are working with third-party types you cannot extend.
Definitions add friction to prevent bugs. Aliases remove friction for convenience. Pick the right level of friction for your domain.