Methods on Custom Types
Learn how to define methods on non-struct named types and implement the Stringer interface for custom string representations in Go.
A method in Go is a function tied to a particular type through a receiver. Methods on structs are the most common use case. But Go’s type system is broader: you can define a method on any type you declare, not just structs. That includes types built from a simple underlying type like int or string, or even composite types like slices. This ability lets you attach behaviour to domain concepts that might otherwise be plain values — a temperature, a user ID, a priority level — without wrapping them in a struct.
This chapter covers two applications of that idea: attaching methods directly to non-struct named types, and using the special Stringer interface to control how those types print.
Methods on Non-Struct Named Types
Go lets you declare a new named type from any existing type. The new type has the same underlying representation but is a distinct type in the type system. You can then define methods on that new type. The only hard restriction is that the type and its methods must live in the same package; you cannot reach into another package — including the language’s built‑in types like int or string — and attach methods to them.
This feature is often used to give semantic meaning to a primitive. Instead of passing a raw float64 around as a temperature, you create type Celsius float64 and equip it with methods that know how to convert, validate, or display that temperature. The code that uses Celsius can then never accidentally mix it up with a float64 representing something else, and the operations on it live right next to the type.
Same Package Only:
You cannot define a method on a type that belongs to another package. Attempting to add a method to int, string, or any type from an imported library will cause a compile‑time error. The workaround is to define your own named type in your package using the other type as its underlying type, and then attach methods to that.
Defining a Method on a Simple Named Type
Start with a custom type based on int. The receiver syntax is identical to what you have already seen with struct receivers.
package main
import "fmt"
// Celsius is a temperature in degrees Celsius.
type Celsius float64
// Fahrenheit converts Celsius to Fahrenheit.
func (c Celsius) Fahrenheit() float64 {
return float64(c)*9/5 + 32
}
func main() {
temp := Celsius(25.0)
fmt.Printf("%v°C is %v°F\n", temp, temp.Fahrenheit())
}
This code creates a type Celsius whose underlying type is float64. The method Fahrenheit takes a value receiver of type Celsius and returns the equivalent temperature in Fahrenheit as a plain float64. Because c is a Celsius, you need to explicitly convert it to float64 before the arithmetic.
Run the program and you will see:
25°C is 77°F
Notice that temp is created by converting the literal 25.0 to Celsius. After that, you can call temp.Fahrenheit() directly, just like a method on a struct. The conversion step is necessary because 25.0 is an untyped constant; without the conversion, Go cannot infer that you meant Celsius.
Methods with Pointer Receivers on Non-Struct Types
Value receivers work when the method only reads data or produces a new value. But sometimes you want the method to modify the original variable. That calls for a pointer receiver, even when the underlying type is a primitive.
package main
import "fmt"
type Counter int
// Increment adds n to the Counter. It needs a pointer receiver so
// the change survives the method call.
func (c *Counter) Increment(n int) {
*c += Counter(n)
}
// Value returns the current count as a plain int.
func (c Counter) Value() int {
return int(c)
}
func main() {
var clicks Counter
clicks.Increment(1)
clicks.Increment(4)
fmt.Println("Total clicks:", clicks.Value())
}
Output:
Total clicks: 5
Here, Increment has a pointer receiver *Counter, so the addition inside the method modifies the original clicks variable. Value has a value receiver because it only reads the data. This pattern — exposing a value receiver for read‑only access and a pointer receiver for mutations — is common even for simple named types.
Don’t Overlook the Dereference:
Inside a method with a pointer receiver, the receiver variable is a pointer. For a type like Counter based on int, you must explicitly dereference it with *c to reach the underlying integer. For structs, Go’s automatic dereferencing of fields means you rarely write (*p).Field, but for non‑struct types you must be explicit.
When to Choose a Named Type Over a Struct
If the data really is a single value with domain‑specific behaviour, a named type is often clearer than a struct with one field. A type UserID string can have a method Valid() bool, and you pass it around as a simple value. A struct with one field would add ceremony without benefit. The decision is not about performance — the underlying memory layout is the same — but about what the type communicates to the reader.
The Stringer Pattern
The fmt package defines an interface named Stringer:
type Stringer interface {
String() string
}
Any type that implements a String() string method automatically satisfies this interface. When you pass a value to fmt.Print, fmt.Println, fmt.Sprintf, and related functions, they check whether the value implements Stringer. If it does, they call String() and use the returned string instead of the default format. This is the standard way to control how your custom types print in Go.
Why the Stringer Pattern Exists
Without Stringer, fmt.Println on a custom type prints a default representation that is often unhelpful — something like main.Celsius(25) or just the underlying number with no context. That is fine for debugging, but when you want a human‑readable output — "25°C" or "user-42 (active)" — you need a way to inject your own formatting logic. Stringer gives you exactly that hook, and because it is part of the standard library, every formatting function respects it automatically.
Infinite Recursion Trap:
Never call fmt.Sprint or fmt.Sprintf on the receiver itself inside the String() method. The formatting function will try to call String() again, leading to an infinite loop and a runtime stack overflow. Instead, format the underlying fields directly or use strconv functions.
Implementing Stringer on a Non-Struct Type
The simplest Stringer implementation returns a descriptive string. For the Celsius type:
package main
import "fmt"
type Celsius float64
func (c Celsius) String() string {
return fmt.Sprintf("%g°C", c)
}
func main() {
temp := Celsius(23.5)
fmt.Println("Current temperature:", temp)
// You can also call String() explicitly, but you rarely need to.
fmt.Println(temp.String())
}
Output:
Current temperature: 23.5°C
23.5°C
fmt.Println received a Celsius value, detected the String() method, and used it. The second line shows that you can also call String() directly, but that is mostly useful inside other formatting code — the whole point is that callers do not need to.
Notice that String() uses fmt.Sprintf with the raw float value c, not with the whole Celsius value. If we had written fmt.Sprintf("%v", c), fmt would call String() again, causing infinite recursion. This is the most common mistake when implementing Stringer. Always format the underlying primitive directly or use functions from the strconv package.
Pointer Receivers and Stringer
Go’s method set rules matter here. If you define String() with a pointer receiver, then only *Celsius satisfies Stringer, not Celsius. fmt.Println on a plain Celsius value will fall back to the default format.
package main
import "fmt"
type Celsius float64
// String with pointer receiver — only *Celsius satisfies Stringer.
func (c *Celsius) String() string {
return fmt.Sprintf("%g°C", *c)
}
func main() {
t := Celsius(10)
// Prints default format because t is a value, not a pointer.
fmt.Println(t)
// Pass a pointer to get the custom format.
fmt.Println(&t)
}
Output:
10
10°C
Value or Pointer Receiver Matters for Stringer:
For a type to automatically print its custom format when passed by value, String() must have a value receiver. A pointer receiver works only when you explicitly pass a pointer. This is a common source of confusion — you implement String() and then fmt.Println(myValue) ignores it.
In practice, most Stringer implementations use a value receiver. They only need to read the data, not mutate it. If your type is large, you can still use a pointer receiver, but then you must remember to pass a pointer every time you print it. For types built on primitives, the cost of copying the value is negligible, so a value receiver is usually the right choice.
A Step‑by‑Step Example: Implementing Stringer for a Priority Type
Let’s work through a complete example that brings together the concepts from this chapter: a custom type with a method and a Stringer implementation.
Step 1: Define the Custom Type
Create a named type based on int to represent a task priority, and add a method that checks whether the priority is valid.
package main
import "fmt"
// Priority represents a task priority level.
type Priority int
const (
Low Priority = 1
Medium Priority = 2
High Priority = 3
)
// IsValid returns true if the priority is within the expected range.
func (p Priority) IsValid() bool {
return p >= Low && p <= High
}
Step 2: Implement the String() Method
Add a String() method so that fmt.Println prints the priority as a human‑readable label rather than a raw integer.
func (p Priority) String() string {
switch p {
case Low:
return "Low"
case Medium:
return "Medium"
case High:
return "High"
default:
return fmt.Sprintf("Unknown(%d)", p)
}
}
Step 3: Use the Type with fmt.Println
Create a few priority values and print them. The fmt package will call String() automatically.
func main() {
p1 := Medium
p2 := Priority(5)
fmt.Println("First task priority:", p1)
fmt.Println("Second task priority:", p2)
if p1.IsValid() {
fmt.Println("p1 is valid")
}
}
Running the program produces:
First task priority: Medium
Second task priority: Unknown(5)
p1 is valid
Everything Is Working:
When you see Medium printed instead of the raw number 2, the Stringer interface is working. The fmt package detected your String() method and used it without any extra code on your part.
Notice how the Unknown case still produces a formatted string that includes the underlying integer. That is safe because the formatting function uses %d on the raw p value, which is just the integer — no infinite recursion.
Practical Use Cases for the Stringer Pattern
The Stringer pattern shows up throughout Go’s standard library and in many production codebases:
- Custom types for IDs:
type UserID stringwith aString()method that masks or formats the ID for logging. - Units of measurement:
type Meters float64that prints"3.2 m". - Enum-like constants: The
Priorityexample above is a common way to replace raw integer constants with self‑describing types. - Debugging helpers: Complex internal types can implement
String()to produce a compact summary, makingfmt.Printlna powerful debugging tool without extra formatting code scattered everywhere.
Any time you find yourself writing the same formatting logic repeatedly whenever you print a value, a Stringer implementation can centralize that logic and keep call sites clean.
Summary
Custom types with methods allow you to attach behaviour to values that would otherwise be plain primitives, giving them semantic weight and operations that live close to the data. This chapter covered two manifestations of that idea. Defining methods on non‑struct named types turns a bare int or string into a self‑aware domain concept. The Stringer pattern connects that idea to Go’s standard formatting machinery, letting your types control their own printed representation without callers needing to know the details.
The biggest insight to take away is that Go’s method system is not about inheritance or class hierarchies — it is about associating behaviour with the data it operates on, regardless of whether that data is a struct, a primitive, or something else entirely. Once you internalize that, you start seeing opportunities to replace raw values with lightweight types that carry their own logic.