Methods on Non-Struct Named Types

How to define methods on named types built from primitives, slices, maps, and functions in Go, and the same-package constraint that governs them

When people first encounter Go methods, the examples almost always involve structs. A Rectangle with an Area() method, a BankAccount with Deposit() — the receiver is invariably some struct type. That pattern is so common it can leave the impression that methods belong to structs. They do not. In Go, you can define a method on any type you create with the type keyword, as long as it lives in the same package as the method definition. That includes types built from primitives like int and float64, composite types like slices and maps, and even function signatures.

A named type in Go is simply a type you give a name to using a type definition. Writing type Celsius float64 creates a brand-new type called Celsius whose underlying representation is a float64. That new type can carry its own methods — methods that a bare float64 can never have. This is not a quirk or an edge case. It is a deliberate part of Go's type system and shows up throughout the standard library.

The Same-Package Constraint

Go's most important rule for methods sits quietly in the language specification and trips up nearly every newcomer at least once: you can only define methods on types declared in the same package as the method. You cannot reach into another package — including the language's own built-in types — and attach behavior to its types.

The compiler enforces this unconditionally. If you attempt to define a method directly on int, string, or []byte, you get a hard error.

// This will NOT compile.
func (n int) double() int {
    return n * 2
}
// Compiler error: cannot define new methods on non-local type int

Compilation Will Fail:

Attempting to define a method on a type declared in another package — including all built-in types — is a compile-time error. There is no workaround, no compiler flag, and no exception. The type must be yours.

The reason is architectural. If any package could add methods to int, two different packages could add conflicting methods with the same name. The compiler would have no way to decide which one to call. Go avoids this entire class of problem by limiting method definitions to the package that owns the type.

The solution is straightforward: create a named type in your own package based on the built-in type you want to extend.

package main
import "fmt"
type myInt int
func (m myInt) double() myInt {
    return m * 2
}
func main() {
    n := myInt(5)
    fmt.Println(n.double()) // Output: 10
}

The type myInt belongs to package main, so main can define methods on it. Underneath, it is still an int — it behaves like one in arithmetic, takes up the same memory, and compiles to the same instructions. But now it carries a method that a plain int never could.

When you run this, you see 10. The expression m * 2 works because myInt inherits the arithmetic operators of its underlying type, int. What it does not inherit is any methods — but since int has no methods, that distinction matters only when you chain named types together.

Methods on Numeric Named Types

Numeric types are the most common non-struct receiver in practice. A bare float64 carries no semantic meaning — it could be a temperature, a distance, a probability, or a bank balance. By naming the type, you attach meaning, and by giving it methods, you attach behavior that respects that meaning.

Consider a temperature type that should never be compared or combined with arbitrary numbers without explicit conversion.

package main
import (
    "fmt"
    "math"
)
type Celsius float64
type Fahrenheit float64
func (c Celsius) ToFahrenheit() Fahrenheit {
    return Fahrenheit(c*9.0/5.0 + 32.0)
}
func (f Fahrenheit) ToCelsius() Celsius {
    return Celsius((f - 32.0) * 5.0 / 9.0)
}
func (c Celsius) Round() Celsius {
    return Celsius(math.Round(float64(c)))
}
func main() {
    boiling := Celsius(100.0)
    boilingF := boiling.ToFahrenheit()
    fmt.Printf("%v C = %v F\n", boiling, boilingF)
    // Output: 100 C = 212 F
    bodyTemp := Fahrenheit(98.6)
    fmt.Printf("%v F = %.1f C\n", bodyTemp, bodyTemp.ToCelsius().Round())
    // Output: 98.6 F = 37 C
}

The method ToFahrenheit() returns a Fahrenheit — not a float64. This means you cannot accidentally pass a Celsius value to a function expecting Fahrenheit, or vice versa. The type system now guards against the kind of unit-mixing errors that have caused real-world disasters.

Arithmetic on Named Numerics Returns the Named Type:

Operations like c * 9.0 / 5.0 + 32.0 on a Celsius value produce a Celsius result, not a float64. In the example above, the return expression Fahrenheit(...) is an explicit conversion — without it, the function would try to return a Celsius as a Fahrenheit, which the compiler would reject. Always check your return types when doing arithmetic inside methods on named numerics.

The conversion back and forth between the named type and its underlying numeric type is explicit — you write float64(c) or Celsius(37.0). This explicitness is a feature. It forces you to acknowledge when you are crossing the boundary between a domain-specific type and a general-purpose number.

Methods on Slice Named Types

A plain []string is a container. A named slice type with methods becomes a collection with domain-specific operations — a stack, a set, a sorted list, or any abstraction that fits naturally on top of a slice.

package main
import "fmt"
type StringStack []string
func (s *StringStack) Push(item string) {
    *s = append(*s, item)
}
func (s *StringStack) Pop() (string, bool) {
    if len(*s) == 0 {
        return "", false
    }
    last := len(*s) - 1
    item := (*s)[last]
    *s = (*s)[:last]
    return item, true
}
func (s StringStack) Peek() (string, bool) {
    if len(s) == 0 {
        return "", false
    }
    return s[len(s)-1], true
}
func (s StringStack) Len() int {
    return len(s)
}
func main() {
    var stack StringStack
    stack.Push("first")
    stack.Push("second")
    stack.Push("third")
    fmt.Println("Length:", stack.Len()) // Output: Length: 3
    top, ok := stack.Peek()
    fmt.Println("Top:", top, ok) // Output: Top: third true
    popped, _ := stack.Pop()
    fmt.Println("Popped:", popped) // Output: Popped: third
    fmt.Println("Length:", stack.Len()) // Output: Length: 2
}

Notice the receiver choices. Push and Pop use pointer receivers (*StringStack) because they modify the slice — Push reassigns the slice header through append, and Pop slices off the last element. Peek and Len use value receivers because they only read. This is the same receiver logic that applies to structs, but with slices there is an additional subtlety: append can allocate a new backing array, so failing to use a pointer receiver on Push means the caller's slice never sees the appended element.

Slices Are Already Reference-Like, So Why a Pointer Receiver?:

A slice value contains a pointer to a backing array, a length, and a capacity. When you pass a slice by value to a method, the method gets a copy of that header — it shares the same backing array. But append may create a new backing array and update the length and capacity fields in the header. If the receiver is a value, those updates are lost when the method returns. A pointer receiver ensures the caller sees the new header.

This stack is a fully usable data structure built on top of []string, yet from the outside it behaves like a proper stack — you push, pop, and peek without ever touching slice indexing directly. The named type wraps the raw slice in an API that communicates intent.

Methods on Map Named Types

Maps benefit from named types in the same way. A map[string]int is a generic data structure; a WordCount or a Cache with named methods tells the reader what the map is for and how it should be used.

package main
import "fmt"
type Counter map[string]int
func (c Counter) Increment(key string) {
    c[key]++
}
func (c Counter) Decrement(key string) {
    c[key]--
    if c[key] == 0 {
        delete(c, key)
    }
}
func (c Counter) Total() int {
    sum := 0
    for _, count := range c {
        sum += count
    }
    return sum
}
func (c Counter) Top(n int) []string {
    type pair struct {
        key   string
        count int
    }
    pairs := make([]pair, 0, len(c))
    for k, v := range c {
        pairs = append(pairs, pair{k, v})
    }
    // Simple selection sort for clarity; production code would use sort.Slice
    for i := 0; i < len(pairs) && i < n; i++ {
        maxIdx := i
        for j := i + 1; j < len(pairs); j++ {
            if pairs[j].count > pairs[maxIdx].count {
                maxIdx = j
            }
        }
        pairs[i], pairs[maxIdx] = pairs[maxIdx], pairs[i]
    }
    result := make([]string, n)
    for i := 0; i < n && i < len(pairs); i++ {
        result[i] = pairs[i].key
    }
    return result
}
func main() {
    counts := Counter{"apple": 5, "banana": 3, "cherry": 8}
    counts.Increment("apple")
    counts.Decrement("banana")
    counts.Decrement("banana")
    counts.Decrement("banana") // banana reaches 0, gets deleted
    fmt.Println("Total items:", counts.Total()) // Output: Total items: 14
    fmt.Println("Top 2:", counts.Top(2))       // Output: Top 2: [cherry apple]
}

Map methods in Go almost never need pointer receivers. A map value is already a pointer to the underlying hash table, so passing it by value still lets the method mutate the map's contents. The Increment and Decrement methods above modify the Counter map directly through a value receiver, and those changes are visible to the caller. The one operation a map method cannot do through a value receiver is reassign the entire map variable to point to a new hash table — but that is rarely needed.

This Counter type encapsulates the bookkeeping of incrementing, decrementing, and querying a frequency map. The caller never accesses the raw map directly, which means the implementation can change without breaking anything. The Decrement method even includes cleanup logic — removing keys that drop to zero — that a raw map access would not provide.

Methods on Function Named Types

Function types can carry methods too. At first glance this seems odd — why would a function need a method? The answer is interface satisfaction. The standard library's net/http package uses this pattern to let a bare function satisfy the http.Handler interface.

package main
import "fmt"
type Transform func(int) int
func (t Transform) ApplyTwice(n int) int {
    return t(t(n))
}
func (t Transform) Compose(other Transform) Transform {
    return func(n int) int {
        return t(other(n))
    }
}
func main() {
    double := Transform(func(n int) int { return n * 2 })
    addOne := Transform(func(n int) int { return n + 1 })
    fmt.Println(double.ApplyTwice(3))
    // Output: 12 — double(double(3)) = double(6) = 12
    combined := double.Compose(addOne)
    fmt.Println(combined(3))
    // Output: 8 — double(addOne(3)) = double(4) = 8
}

The Transform type is a function signature: func(int) int. By defining methods on it, you turn a plain function into a value that carries its own combinator logic. ApplyTwice calls the underlying function twice. Compose returns a new Transform that pipes its input through other first, then through the original function.

This Pattern Satisfies Interfaces:

A named function type with methods can satisfy any interface whose method set matches. If you have an interface with a single method and you want to adapt a plain function to it, wrapping that function in a named type with the required method is the standard Go idiom. The http.HandlerFunc type in the standard library does exactly this: type HandlerFunc func(ResponseWriter, *Request) with a ServeHTTP method that calls itself.

The mental model is worth stating explicitly: a named function type is not a function value. It is a type whose values are functions. When you write Transform(func(n int) int { return n * 2 }), you are converting a function literal into a value of type Transform. That value carries both the function body and access to all methods defined on Transform.

Pointer Receivers on Non-Struct Types

The choice between value and pointer receivers applies to non-struct types exactly as it does to structs, but the consequences can feel less intuitive because the values involved are small. A myInt or a Celsius is the same size as an int or float64 — a few bytes. Copying them is cheap, so the performance argument for pointer receivers largely disappears. What remains is the correctness argument: do you need the method to mutate the receiver in a way the caller can observe?

package main
import "fmt"
type Counter int
func (c Counter) Increment() {
    c++
    fmt.Println("Inside Increment:", c)
}
func (c *Counter) IncrementPtr() {
    *c++
    fmt.Println("Inside IncrementPtr:", *c)
}
func main() {
    var n Counter
    n.Increment()
    fmt.Println("After Increment:", n)
    // Inside Increment: 1
    // After Increment: 0  ← the original is unchanged
    n.IncrementPtr()
    fmt.Println("After IncrementPtr:", n)
    // Inside IncrementPtr: 1
    // After IncrementPtr: 1  ← the original is now 1
}

The value receiver Increment receives a copy of n. It increments that copy to 1, prints it, and then the copy is discarded. The original n in main remains 0. The pointer receiver IncrementPtr operates on the original n through a pointer, so the increment persists.

Value Receivers on Named Types Are Silent No-Ops for Mutation:

A method with a value receiver that attempts to mutate the receiver compiles without warning and runs without error — but the caller sees no change. This is the same behavior as struct value receivers, but it is easier to miss on small types because there is no compiler hint that you forgot the pointer. If a method's purpose is to change the receiver, it must use a pointer receiver, regardless of how small the type is.

For numeric and other small types used as counters, accumulators, or state holders, pointer receivers are the correct choice when mutation is the goal. For types like Celsius or Transform where methods produce new values rather than modify existing ones, value receivers are natural and correct.

Method Sets and Interface Satisfaction

Every type in Go has a method set — the collection of methods that can be called on values of that type. The method set of a non-struct named type follows the same rules as any other type. Methods with value receivers appear in the method sets of both the type T and the pointer type *T. Methods with pointer receivers appear only in the method set of *T.

This matters when interfaces enter the picture. A value of type T can satisfy an interface only if all the required methods are in T's method set — which excludes pointer-receiver methods.

package main
import "fmt"
type Adder int
func (a Adder) Add(b Adder) Adder {
    return a + b
}
func (a *Adder) Double() {
    *a = *a * 2
}
type Combinable interface {
    Add(Adder) Adder
}
func CombineAll(c Combinable, values []Adder) Adder {
    result := c
    for _, v := range values {
        result = result.Add(v)
    }
    return result
}
func main() {
    var x Adder = 10
    fmt.Println(CombineAll(x, []Adder{2, 3, 4}))
    // Output: 19 — 10 + 2 + 3 + 4
    // Compiler error: cannot use &x as Combinable — Double is not in the interface
    // But x itself works because Add is on the value receiver.
}

In practice, non-struct types tend to use value receivers heavily because they represent small, immutable-ish values. This makes them natural candidates for interface satisfaction — a Celsius or a Transform can slot into interfaces that expect arithmetic or transformation behavior without any pointer ceremony.

The standard library exploits this. The sort package defines StringSlice as type StringSlice []string with methods Len(), Less(), and Swap() — all value receivers on a slice type. A StringSlice value directly satisfies sort.Interface, letting you sort a slice of strings by converting it to sort.StringSlice and calling sort.Sort() on it. No pointers, no wrapping structs, just a named slice type with three methods.

Common Misunderstandings

Several misconceptions about non-struct methods appear repeatedly in forums and code reviews. Each is worth naming directly.

A named type does not inherit methods from the type it is based on. If you define type MyInt int and int somehow had methods (it does not, but imagine a scenario with a custom type), MyInt would not have those methods. This is the most important difference between type definition and struct embedding. Embedding a type inside a struct promotes its methods. Defining a new named type from it does not.

type RichInt int
func (r RichInt) Describe() string {
    return fmt.Sprintf("Value: %d", r)
}
type PoorInt RichInt
// PoorInt has NO methods. Describe() is not available on PoorInt values.
// This compiles: var p PoorInt = 10
// This fails: p.Describe() — undefined

This trips people up when they try to create a "wrapper" type by defining a new named type from an existing one that already has methods. The wrapper gets the underlying data representation but none of the behavior.

You cannot define methods on unnamed types. An unnamed type is one without a name — a composite literal type like []string, map[string]int, or func(int) bool. These cannot be receivers because they have no name to attach the method to. You must use type to name them first.

Pointer types created with type cannot have methods. Writing type MyPtr *int creates a named pointer type. That type cannot be a receiver base type. The rule is that the receiver base type must not itself be a pointer type. You can still use pointer receivers on other named types — func (m *MyInt) Double() is fine — but the base type MyInt is not a pointer.

Value receivers on map and slice types can still mutate the underlying data. This is the exception that confuses people coming from the struct receiver rules. Maps and slices contain internal pointers to shared data. A copy of a map or slice header still points to the same hash table or backing array. So func (c Counter) Increment(key string) can modify the map even though Counter is passed by value. The only thing a value receiver cannot do with a map or slice is reassign the variable to point to an entirely new map or array — an operation that is rare in method bodies anyway.

Where Non-Struct Methods Appear in Practice

The standard library uses non-struct methods in several places, and recognizing the pattern helps you read Go code more fluently.

time.Duration is perhaps the most familiar example. It is defined as type Duration int64 in the time package and carries methods like Hours(), Minutes(), Seconds(), Milliseconds(), and String(). A Duration is just an int64 representing nanoseconds, but the methods give it a rich API for converting between time units and formatting itself. You never manipulate the raw int64 directly — you call d.Hours() instead of dividing by a magic constant.

sort.StringSlice is type StringSlice []string. It implements sort.Interface with three methods, letting you sort a []string by converting it to a sort.StringSlice and passing it to sort.Sort(). The alternative would be writing three standalone functions that take a []string, which is less discoverable and more verbose at the call site.

net/http.HandlerFunc is type HandlerFunc func(ResponseWriter, *Request). It has a single method, ServeHTTP, that calls the underlying function. This lets you write http.HandlerFunc(myFunction) and pass it anywhere an http.Handler is expected — the method bridges the gap between a plain function and the interface the router expects.

In your own code, the pattern is valuable whenever a primitive or composite type takes on a specific role in your domain. A type UserID int64 prevents accidentally passing a product ID where a user ID is expected. A type Token string can carry a Validate() method. A type Set map[string]struct{} can offer Add, Remove, and Contains methods that read far more clearly than raw map operations scattered through business logic.

Summary

Methods on non-struct named types are not a workaround or a special case. They are the natural consequence of Go's decision to attach methods to types rather than classes. Any type you define in your package — numeric, slice, map, or function — can carry methods. The only hard constraint is the same-package rule: the type and its methods must be declared together.

The practical value of this feature lies in domain modeling. A named type with methods communicates more than a raw int, string, or []float64 ever can. It tells the reader what the value represents, what operations make sense for it, and what invariants the code expects. The compiler enforces those distinctions — you cannot accidentally mix a Celsius with a Fahrenheit or pass a raw int to a function expecting a Duration.

When a named type implements String() string, Go's formatting functions use that method to produce human-readable output. The standard library's time.Duration does this, and so can any type you define.