JavaRush /Courses /Go SELF /Method set in Go: why T and *T see different methods

Method set in Go: why T and *T see different methods

Go SELF
Level 22, Lesson 2
Available

1. The basic model: interface, method set, and automatic & insertion

When you are just starting to write Go, it feels like methods are simply “functions with a dot”: for example, t.MarkDone(). And often the syntax really is friendly: the compiler can even “guess” that you meant to call a pointer method and insert &.

The problems start where Go becomes “grown-up”: when we begin putting values into interfaces and want “any type with method X” to satisfy a common contract. For example, “any object that can be completed” — a conditional Completer. And then suddenly: the call t.MarkDone() compiles, but the assignment var c Completer = t does not.

To understand this, you need to get familiar with the term method set: “the set of methods that a type exposes to the world.” And for T and *T, that set is different.

An interface is a set of methods

An interface in Go is a type that describes which methods a value must have so that it can be used in a particular place. For example, the built-in interface error requires the method Error() string — therefore, any type that has such a method can be an error.

For this lecture, one idea is enough: a type matches an interface if it has the required methods. But then a key question appears: which methods does a type “have” — with a receiver of T or *T? This is where the method set begins.

The method set rule for T and *T

A method set is the list of methods that the compiler considers to belong to a type in the context of interface checking and some other rules.

The most important rule:

Value type Which methods are included in the method set
T
methods declared with receiver (t T)
*T
methods declared with receiver (t T) and (t *T)

In short: *T “sees” more methods than T.

This rule often surprises beginners because in dot calls the compiler sometimes “fills in” & or *. But for interfaces, strict math applies: it matches or it does not — no guessing and no “well, it could take the address.”

Why t.M() may work, but the interface still “won’t accept” t

Go has a convenience feature: if you have an addressable variable t, and the type has a pointer method func (t *T) M(), then the call t.M() may compile. In that case the compiler effectively does something like: (&t).M().

But this automatic insertion works only for dot calls and only when the value is addressable. It does not mean that T gained this method in its method set. And interface compatibility is checked exactly by the method set — strictly.

You can visualize it schematically like this:

flowchart TD
    A[We have a value t of type T] --> B{"Do we want to call t.M()?"}
    B -->|Yes, t is addressable| C[The compiler may insert &t]
    B -->|Yes, t is not addressable| D[Compilation error]

    A --> E{Do we want to assign t to an interface?}
    E --> F[Check the method set of T]
    F -->|Missing the required method| G[Compilation error]
    F -->|Has the required method| H[OK]

2. Practice and pitfalls

Task, MarkDone, and the Completer interface

Let’s continue a learning mini-project (a simple “task tracker”). We have a Task, and we want to be able to mark it as done.

Let’s define the type and the method:

package main

type Task struct {
	ID    int
	Title string
	Done  bool
}

func (t *Task) MarkDone() {
	t.Done = true
}

The logic is simple: the method changes state, so the receiver is *Task.

Now let’s create the “can be completed” interface and a function that works with that interface:

package main

import "fmt"

type Completer interface {
	MarkDone()
}

func Complete(c Completer) {
	c.MarkDone()
}

func main() {
	t := Task{ID: 1, Title: "Learn method sets"}
	// Complete(t)   // does not compile
	Complete(&t)
	fmt.Println(t.Done) // true
}

The key point: Task does not satisfy the Completer interface, because its method set (for Task) contains only methods with receiver Task, while MarkDone is declared on *Task. But *Task does satisfy it, because the method set of *Task includes both methods of Task and methods of *Task.

And here a common thought appears: “But I can call t.MarkDone() directly!”. Yes, you can — because t is addressable, and the compiler will insert &t. But it will not do that for interface assignment.

Why Go does not insert & when assigning to an interface

Imagine Go allowed:

var c Completer = t // suppose this was allowed

Then the interface c would hold a copy of t (because “everything is passed by value”). And when we called c.MarkDone(), the method would work with a *Task — meaning the address of the copy inside the interface, not the original variable t.

And then we’d get the classic kind of chaos: “why doesn’t my Done flag change?” and “why does it sometimes work and sometimes not?” So Go chooses a strict and predictable model: if a method is potentially mutating (pointer receiver), then T is not considered to implement an interface that requires that method. This protects you from a huge number of “silent” bugs.

Addressability and method set are different things

It is easy to mix up two topics here:

First — the method set: which methods belong to T and *T, and what satisfies an interface.

Second — addressability (addressable): whether you can take the address of a value, i.e., apply &.

Sometimes an error looks like “the type has the method, but you can’t call it,” and a beginner thinks “it’s method set.” But in reality it is addressability.

For example, if we had a function that creates a task:

package main

type Task struct {
	ID   int
	Done bool
}

func (t *Task) MarkDone() { t.Done = true }

func NewTask(id int) Task {
	return Task{ID: id}
}

func main() {
	// NewTask(1).MarkDone() // does not compile: the function result is not addressable
	t := NewTask(1)
	t.MarkDone() // ok: t is addressable
}

Here the reason is not interfaces. The reason is that NewTask(1) returns a “temporary value,” and you cannot take its address. But a pointer method requires an address.

Understanding the difference matters: the method set answers “does the type satisfy the interface,” while addressability answers “can we call a pointer method via dot at this particular place.”

A common bug: range gives you a copy, and a pointer method mutates the copy

We have a list of tasks:

package main

import "fmt"

type Task struct {
	ID   int
	Done bool
}

func (t *Task) MarkDone() { t.Done = true }

func main() {
	tasks := []Task{{ID: 1}, {ID: 2}}

	for _, t := range tasks {
		t.MarkDone() // compiles, but mutates a COPY of t
	}

	fmt.Println(tasks[0].Done, tasks[1].Done) // false false
}

Why? Because for _, t := range tasks creates a variable t that is a copy of the element. Yes, the variable t is addressable, so t.MarkDone() compiles (the compiler takes &t). But that is the address of the copy, not the address of the element in the array/slice.

The correct approach is to iterate by index and take the address of the real element:

package main

import "fmt"

type Task struct {
	ID   int
	Done bool
}

func (t *Task) MarkDone() { t.Done = true }

func main() {
	tasks := []Task{{ID: 1}, {ID: 2}}

	for i := range tasks {
		tasks[i].MarkDone()
	}

	fmt.Println(tasks[0].Done, tasks[1].Done) // true true
}

This is not “about method set” in the pure sense, but it is an excellent illustration of how pointer methods, addressability, and “copies” from range combine into one storyline.

A receiver selection strategy with method set in mind

When you design a type for your application, it’s better to choose receivers not “by vibe,” but by a simple rule: you want your type to be convenient and predictable both on its own and when used through interfaces.

If a method mutates the state of a struct (Done, Balance, Count, “internal cache” — anything), a pointer receiver is almost inevitable. And then you decide up front: in interfaces and functions, I will pass *Task, not Task.

If a method only reads data and returns a derived result (for example, IsDone() bool or Title()), then a value receiver is usually more convenient, because it will automatically be available on both Task and *Task (since the method set of *T includes methods of T).

Let’s show it with a small snippet:

package main

import "fmt"

type Task struct {
	Title string
	Done  bool
}

func (t Task) IsDone() bool { return t.Done }

func main() {
	t := Task{Title: "Read", Done: true}
	fmt.Println(t.IsDone())    // true
	fmt.Println((&t).IsDone()) // true
}

Here IsDone() is declared on Task, so it is included both in the method set of Task and in the method set of *Task. That makes the type more flexible.

3. Common mistakes

Mistake #1: confusing “dot call works” with “the type implements the interface”.
If t.MarkDone() compiles, that does not mean that Task satisfies the Completer interface. A dot call may include automatic insertion of &t, but interface compatibility is checked strictly by the method set. A reliable mental check: “method on *T — so I pass *T into the interface.”

Mistake #2: declaring a mutating method on a value receiver and expecting the original object to change.
When a method is declared as func (t Task) MarkDone(), it works with a copy. The code “looks nice,” but the state does not change. If the method mutates fields — you almost always need *Task.

Mistake #3: trying to fix everything with “let’s use *T everywhere”.
This solves some problems, but creates others: inconvenience with non-addressable temporary values, extra &, and sometimes a murkier API. A good style is to keep a consistent policy on the type: mutating methods on *T, read-only methods often on T, and understand how this affects the method set.

Mistake #4: mutating slice elements through for _, v := range plus a pointer method.
This bug is very sneaky: the code compiles, the method is called, but the changes “don’t stick.” The reason is that v is a copy. If you need to mutate elements — iterate by index (for i := range) and call the method on tasks[i].

Mistake #5: forgetting that an interface stores a value, not a “reference to a variable”.
An interface is not a “magic pointer.” If you want methods to mutate the original state, the interface must hold a pointer (*T), and the method set must allow it.

1
Task
Go SELF, level 22, lesson 2
Locked
Double-check
Double-check
1
Task
Go SELF, level 22, lesson 2
Locked
Wallet top-up
Wallet top-up
1
Task
Go SELF, level 22, lesson 2
Locked
System switch
System switch
1
Task
Go SELF, level 22, lesson 2
Locked
Task pipeline
Task pipeline
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION