JavaRush /Courses /Go SELF /How method sets affect interface implementations

How method sets affect interface implementations

Go SELF
Level 24, Lesson 4
Available

1. Foundation: receiver and method set

Sometimes it feels like the Go compiler was created specifically to ruin your mood. You write clean code, the type has a method Add, the interface also has Add, but assigning to the interface suddenly does not compile. At that moment you want to say: “Come on, compiler, I can see the method is there!” — but the compiler sees the world a bit more formally.

That formalism is useful: it protects you from ambiguity and from hidden data copying. In Go, the idea “values and pointers are different things” matters, and the set of methods available on T versus on *T differs too. That set is called the method set. This is exactly what Go uses to decide whether a type implements an interface.

If you memorize two or three method set rules, you will stop “poking the compiler with a stick” and start predicting its behavior. And that, admittedly, is nicer than guessing who wins today: you or go build.

Value receiver and pointer receiver in human terms

Before diving into method sets, it helps to restore intuition. A method can be declared either on a value or on a pointer. It looks like this:

func (t Task) TitleUpper() string { ... }   // value receiver
func (t *Task) MarkDone() { ... }           // pointer receiver

A value receiver (Task) means “the method works with a copy of the value”. Typically it either changes nothing, or it changes things “inside itself”, but those changes do not escape because a copy was modified.

A pointer receiver (*Task) means “the method works with the same object”, so changes are visible outside. This is the main choice when a method must mutate a struct’s state.

But there is one more effect that beginners often miss: the receiver affects whether T implements an interface, or whether you need *T. And that is directly tied to the method set.

What is a method set: a simple definition and a rules table

A type’s method set is the “list of methods the compiler considers to belong to that type” in the context of interface checks and method calls. We care specifically about the interface part: for a type to implement an interface, its method set must contain all methods of that interface.

The key rule in Go is:

Type we are considering Which methods are in the method set
T
methods with receiver T
*T
methods with receiver T and methods with receiver *T

This implies something very practical: if you declare a method on *T, then a value of type T may not implement the interface that requires that method. But *T will.

This situation is so common that you will see it even in language examples and discussions: for instance, when interfaces are analyzed in the context of methods with a pointer receiver, you can clearly see that “the type does not satisfy the interface because the method has a pointer receiver”.

2. Example: a “task manager” and the TaskStore interface

To keep things concrete, let’s continue our educational mini‑project. Suppose we have a task and a task store (for now in memory, no files and no DB — we are just practicing architectural thinking).

Let’s start with the model:

package main

type Task struct {
	ID    int
	Title string
	Done  bool
}

Now define the interface that a “consumer” needs. For example, our code wants to add a task and mark it as done:

package main

type TaskStore interface {
	Add(t Task)
	MarkDone(id int) bool
}

Let’s make a simple in-memory implementation:

package main

type MemStore struct {
	tasks []Task
}

func (s *MemStore) Add(t Task) {
	s.tasks = append(s.tasks, t)
}

func (s *MemStore) MarkDone(id int) bool {
	for i := range s.tasks {
		if s.tasks[i].ID == id {
			s.tasks[i].Done = true
			return true
		}
	}
	return false
}

Note: both methods are on *MemStore because we mutate the internal slice (add items and change fields). That looks reasonable.

And now—the moment of truth. We want to write a function that accepts the interface:

package main

func Seed(store TaskStore) {
	store.Add(Task{ID: 1, Title: "Buy tea"})
	store.Add(Task{ID: 2, Title: "Don't forget the tea"})
}

And in main:

package main

import "fmt"

func main() {
	var s TaskStore = MemStore{} // want it like this
	Seed(s)
	fmt.Println("ok")
}

This code will not compile. The compiler will say something like: MemStore does not implement TaskStore (Add has pointer receiver).

Why? Because TaskStore requires the methods Add and MarkDone. For MemStore (as a value), the method set contains only methods with receiver MemStore, but our methods are declared on *MemStore. So a value MemStore{} does not implement the interface.

The correct solution is to pass a pointer:

package main

import "fmt"

func main() {
	var s TaskStore = &MemStore{} // now this is *MemStore
	Seed(s)
	fmt.Println("ok") // ok
}

Now the dynamic type in the interface is *MemStore, and its method set includes methods with receiver MemStore and with receiver *MemStore, so everything required is present.

4. “But I can call the method on a value”: the syntactic sugar trap

This is where confusion usually begins. A student says: “Wait. I can do this: ms := MemStore{}; ms.Add(...) — and it compiles. So why can’t I put it into an interface?”

Let’s show it honestly:

package main

import "fmt"

type MemStore struct{ tasks []int }

func (s *MemStore) Add(x int) {
	s.tasks = append(s.tasks, x)
}

func main() {
	ms := MemStore{}
	ms.Add(10) // compiles!

	fmt.Println(ms.tasks) // [10]
}

This compiles because Go provides a convenience: if you have an addressable value (ms is a variable, it has an address), then when calling a method with a pointer receiver, the compiler implicitly inserts &ms. In other words, what is actually called is (&ms).Add(10).

But the important part: this is call convenience, not a change to the interface implementation rules. For interfaces, the compiler looks strictly at the type’s method set. When you write var s TaskStore = MemStore{}, the compiler is not required (and will not) “magically take the address” of a temporary value to satisfy the interface.

A useful mental model: “calling a method” and “implementing an interface” are checked by similar rules, but not identical ones. A call may use implicit address-taking, but an interface check will not; it is based on the method set.

5. Choosing a receiver with interfaces in mind

When a value receiver is more useful

After the previous section, it is easy to draw the wrong conclusion: “so I should always put methods on pointers”. Not always.

Some types are logically and safely passed by copying: small structs, immutable “values” (for example, a point on a plane), wrapper types around basic numbers, and cases where a method does not mutate anything at all.

Let’s show an example. We’ll create a TaskID type as a “meaningful” type so we don’t accidentally mix up an ID with something else:

package main

import "fmt"

type TaskID int

func (id TaskID) String() string {
	return fmt.Sprintf("task#%d", id)
}

func main() {
	var x fmt.Stringer = TaskID(7)
	fmt.Println(x.String()) // task#7
}

Here, a value receiver is a perfect fit: TaskID is small, cheap to copy, and the String() method does not mutate state. And a nice bonus: both TaskID and *TaskID satisfy fmt.Stringer, because for *T the method set includes the methods of T.

Pointer receiver: two common scenarios and one dangerous one

When we choose a pointer receiver, it is most often one of two scenarios.

The first scenario: we need to mutate state. That is our MemStore: we add a task and change Done. In this case, a pointer receiver is almost inevitable, and then we simply accept this as a fact: the interface will be implemented by *MemStore, not by MemStore.

The second scenario: we do not want to copy a large struct. Even if a method changes nothing, copying a large object may be unnecessary. Here, a pointer receiver is an “optimization” choice, but its cost is more complicated interface compatibility (again, you will need to pass *T everywhere).

There is also a third scenario that looks innocent but can lead to weird bugs: typed nil inside an interface. It is not directly about method sets, but it often comes up specifically with pointer receivers, because a pointer receiver allows you to store nil as a “value” of type *T.

For example, error is an interface. Any type with the method Error() string implements it, including types where that method is declared on *T.

And then it is easy to do this:

package main

import "fmt"

type MyErr struct{ Msg string }

func (e *MyErr) Error() string {
	if e == nil {
		return "<nil MyErr>"
	}
	return e.Msg
}

func bad() error {
	var e *MyErr = nil
	return e // packed a typed nil into interface error
}

func main() {
	fmt.Println(bad() == nil) // false
}

Why false? Because the error interface stores the dynamic type *MyErr and the dynamic value nil. The interface as a whole is not nil. This is the classic interface nil trap, and it is often called out separately in materials about error handling and API design.

What is the connection to method sets? Indirect but practical: a pointer receiver makes it possible for a concrete type to have a nil value, and then the interface you put it into may behave “not as expected” when you check == nil.

How to choose a receiver: think about future calls and interfaces

When you write methods for a type, you are effectively designing a mini API. And the receiver is part of that API. If your type is meant to be used through interfaces, the receiver choice affects ergonomics.

Imagine you have an interface:

type Closer interface {
	Close() error
}

If Close() is declared on *T, then you will have to pass *T into the interface. If Close() is declared on T, then both T and *T will work. On paper, a value receiver seems “more universal”, but it can cause accidental copies and can lead to a method mutating only a copy (if someone later decides to add mutation inside Close()).

In our task app, the rule can be stated simply: MemStore is a “container with internal state”, so a pointer receiver is justified, and an interface variable should store *MemStore. And TaskID is a “value”, so a value receiver is fine.

A useful habit: when defining an interface and a concrete type for it, explicitly say: “Who will live in the interface: T or *T?” That removes half of the future questions.

6. Diagnostics: how to see what is inside an interface

When something does not compile or behaves strangely, you want to see reality rather than guesses. With interfaces, fmt.Printf("%T", x) helps a lot—it shows the dynamic type of the value inside the interface (or "<nil>" if the interface itself is entirely nil).

For example, in our TaskStore:

package main

import "fmt"

type TaskStore interface {
	Add(id int)
}

type MemStore struct{ items []int }

func (s *MemStore) Add(id int) { s.items = append(s.items, id) }

func main() {
	var a TaskStore = &MemStore{}
	fmt.Printf("%T\n", a) // *main.MemStore
}

If you see *main.MemStore, that means the interface really holds a pointer.

And conversely, if you ever suspect a typed nil, this output helps too: you can see that the type inside the interface is a pointer type, even if the “value” there is nil.

7. Common mistakes

Mistake #1: “The method is on *T, but I am trying to put T into an interface”.
Usually it looks like var x I = T{...} and a compile error about a pointer receiver. The reason is almost always the method set: a value of type T does not have methods declared on *T, so T does not implement the interface. Fix it either by changing to &T{...}, or by reconsidering receivers (if there is no mutation and copying is safe).

Mistake #2: “I called t.M() on a value, so it must implement the interface”.
This is a logical trap. A method call allows conveniences like implicit address-taking for an addressable variable. Interface implementation is checked more strictly by the method set. Keeping that in mind removes the feeling that “the compiler is against me”.

Mistake #3: “I made a value receiver for a method that should mutate state, and I am surprised that nothing changes”.
This is the other side of the coin: you chose receiver T, the method honestly mutates a copy, and the original value remains unchanged. This bug is especially nasty when there is a lot of logic inside the method and it feels like “it definitely did something”. In practice, if a method mutates struct fields or must mutate an internal slice/map—almost always you need *T.

Mistake #4: “I hit a typed nil inside an interface and check iface == nil as a sign that the value is absent”.
Most often this happens with error: a function returns (*MyErr)(nil) as an error, and the caller gets err != nil and thinks the operation failed. This is not directly about method sets, but it typically shows up where methods are on *T, and therefore nil becomes a valid “value” for the concrete type. This trap is often highlighted in materials about errors and the error contract.

Mistake #5: “I built a type for an interface, then added a method with a pointer receiver—and everything ‘broke’”.
This happens when a type used to implement an interface as a value, and then you add (or change) a method so that it becomes a pointer receiver. As a result, T stops implementing the interface, and the project starts requiring *T. This is not “bad Go”; it is you changing the API contract. So the receiver is part of design: change it deliberately, especially if the type is already used through interfaces.

1
Task
Go SELF, level 24, lesson 4
Locked
Two appearances
Two appearances
1
Task
Go SELF, level 24, lesson 4
Locked
Number bag
Number bag
1
Task
Go SELF, level 24, lesson 4
Locked
Wallet Methods
Wallet Methods
1
Task
Go SELF, level 24, lesson 4
Locked
Fake nil
Fake nil
1
Survey/quiz
Interfaces, 24 level, 4 lesson
Unavailable
Interfaces
Interfaces
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION