11.5 Methods on Non-Struct Types
Alright, let’s talk about something that makes a lot of new Gophers do a double-take: putting methods on types that aren’t structs. You’ve probably plastered methods all over your User and Account structs. That’s great. But what about when you want to add behavior to, say, a string? Or a slice? Or that custom type you made for a float64?
You absolutely can. In Go, you can define a method on any type you define in your package, provided the type’s underlying definition (its “type literal”) is in the same package. This is the secret handshake. You can’t add a method to a built-in type like string or int directly because you didn’t define them—they belong to the builtin package. But you can create a new type with that as its underlying type, and then that new type is yours. You can do whatever you want to it. Even give it methods.
Let’s get our hands dirty.
Your New Best Friend: The Type Alias (But Not Really)
First, a crucial distinction. When you write type MyString string, you are not creating a type alias. An alias would be type MyString = string, which is just a new name for the exact same type. No, type MyString string creates a brand new, distinct type. It has the same underlying representation as a string, but as far as the Go type system is concerned, MyString and string are about as related as a Cat and a DatabaseConnection. This means you can’t just pass a MyString to a function expecting a string without conversion. This is the price of admission for getting to attach methods.
package main
import "fmt"
// MyString is a new type with string as its underlying type.
type MyString string
// Shout is a method on the MyString type.
// Note the value receiver: (m MyString)
func (m MyString) Shout() string {
return fmt.Sprintf("%s!!!", string(m)) // We have to convert back to string to use with fmt.Sprintf
}
func main() {
myGreeting := MyString("Hello there")
fmt.Println(myGreeting.Shout()) // Output: Hello there!!!
// This won't compile:
// var normalString string = myGreeting
// You must explicitly convert:
normalString := string(myGreeting)
fmt.Println(normalString)
}
Why You’d Bother: The Classic Example
The most common and sensible use case for this is for constraining or enriching simple data types. You want to add guaranteed, type-safe behavior to a simple value.
Let’s say you’re handling money. Using a raw float64 for currency is a one-way ticket to rounding-error hell and you can’t easily add methods to it. Solution? Create a new type.
package main
import (
"errors"
"fmt"
)
type USD float64
// Format returns a formatted string representation of the USD amount.
// A value receiver is perfect here; we're not modifying the original.
func (u USD) Format() string {
return fmt.Sprintf("$%.2f", u)
}
// A pointer receiver! Because we're actually going to modify the value.
func (u *USD) ApplyInterest(rate float64) error {
if rate <= 0 {
return errors.New("interest rate must be positive")
}
*u = *u * USD(1+rate/100)
return nil
}
func main() {
balance := USD(125.5)
fmt.Println(balance.Format()) // Output: $125.50
err := balance.ApplyInterest(5.0) // Wait, what? balance isn't a pointer!
if err != nil {
panic(err)
}
fmt.Println(balance.Format()) // Output: $131.78
}
Hold on. Did you see that? balance is a value of type USD, not *USD. But we just called a pointer receiver method on it. Why did that work? This is Go being helpful. If you have a value x of type T and a method with a pointer receiver func (t *T) SomeMethod(), Go will automatically take the address of x for you and call the method as (&x).SomeMethod(). This is a huge convenience that prevents a whole class of annoying, obvious errors. It works the other way, too. If you have a pointer p and a value receiver method, Go will automatically dereference it ((*p).Method()). This magic is why you often see a mix of value and pointer receiver methods used seamlessly on the same type.
The Slice Dilemma: Handle With Care
You can also define methods on slice, array, map, and channel types. This is where it gets powerful, but also where you can paint yourself into a corner if you’re not thinking about pointer vs. value receivers.
type TransactionLog []string
// Log adds a new transaction. Use a pointer receiver because we're modifying the slice header.
func (tl *TransactionLog) Log(entry string) {
*tl = append(*tl, entry) // Crucial: reassign the new slice back through the pointer.
}
// Latest is a read-only operation. A value receiver is fine.
func (tl TransactionLog) Latest() string {
if len(tl) == 0 {
return "No transactions"
}
return tl[len(tl)-1]
}
func main() {
var log TransactionLog
log.Log("Deposit: $500") // Go automatically does (&log).Log(...)
log.Log("Withdrawal: $100")
fmt.Println(log.Latest()) // Output: Withdrawal: $100
fmt.Printf("%v", log) // Output: [Deposit: $500 Withdrawal: $100]
}
The key here is understanding the slice header. A slice is a small struct-like data structure containing a pointer to an array, a length, and a capacity. When you use a value receiver func (tl TransactionLog) Latest(), you’re getting a copy of that header. The copy still points to the same underlying array, so reading is fine. But when you append in the Log method, you might need to create a new array and update the slice header. If you did this on a copy of the header (a value receiver), the caller would never see that update. The pointer receiver ensures you are modifying the original slice header held in main.
The Gotcha: Interfaces and Non-Structs
Here’s the final exam question. If you have an interface, what methods does your non-struct type need to satisfy it? The rules are exactly the same as for structs. The method set of a defined type T consists of all methods declared with receiver T. The method set of the pointer type *T consists of all methods declared with receiver *T or T.
This means a value of type T can only call value receiver methods. A value of type *T can call both pointer and value receiver methods (thanks to auto-dereferencing).
But for interface satisfaction:
- A value of type
Tsatisfies an interface ifThas all the required value receiver methods. - A pointer of type
*Tsatisfies an interface if*Thas all the required methods. Since*T’s method set includes all methods ofT, this means*Twill satisfy any interface thatTsatisfies, but not necessarily the other way around. If any required method has a pointer receiver, then only*Timplements the interface;Tdoes not.
So, for our USD type, if an interface required the ApplyInterest method (pointer receiver), you could only assign a *USD to that interface variable, not a USD.
The bottom line? Using methods on non-structs is a fantastic way to create clean, safe, and expressive APIs around simple data. Just remember the rules of the game: you need a defined type, and your choice of value or pointer receiver has real, profound consequences for how the type can be used. Choose wisely.