JavaRush /Courses /Go SELF /Interfaces as a set of methods, implicit implementation

Interfaces as a set of methods, implicit implementation

Go SELF
Level 24, Lesson 0
Available

1. Interface as a type: a set of methods

When you’re just starting to program, it’s very natural to think like this: “I have data, so I’ll make a struct, and then I’ll pass that struct everywhere.” And for small programs this works. But as soon as the code starts to grow, two unpleasant habits appear: tying functions to specific types (so they can’t be reused) and dragging extra details into places where they aren’t needed.

An interface in Go is a way to say: “I don’t care what you are; I care what you can do.” It’s like a power outlet: you don’t need to know how the power plant works; if the plug matches the standard, the hair dryer turns on. An interface is exactly that kind of “standard” for code: a set of methods the consumer expects.

Put briefly and strictly, an interface in Go is a type that lists method signatures. Not fields. Not “data.” Only methods.

Historically, that’s exactly how interfaces were perceived: as a “set of methods,” and that’s still the most useful mental model for everyday code.

Interface declaration syntax

Before we start applying interfaces in our mini app, let’s look at the basic syntax. It’s very similar to a struct, except that inside are methods.


package main

type Greeter interface {
	Greet() string
}

Here Greeter is an interface type. It says: “anyone who can Greet() string fits.”

An interface cannot require fields

A very important point that annoys beginners at first (and then saves their lives): you can’t access fields through an interface, because an interface is about behavior, not about data structure.

That is, if you have:

type User struct {
	Name string
}

And var g Greeter = User{Name: "Anna"}, then you won’t be able to do g.Name. Because Greeter “promised” only the Greet method, not the presence of the Name field. This isn’t a “Go limitation”; it’s a fundamental idea: an interface should shield the consumer from implementation details.

2. Implicit implementation: Go has no implements

When people come to Go from Java/C#/TypeScript, they often look for the implements keyword. But it’s not there. Go says: “If a type has the required methods, then it fits.” That’s it.

Why this is convenient:

  • an implementation doesn’t have to “know” what interface it implements;
  • you can add new interfaces “from the consumer side” without changing old types;
  • less coupling (which means less pain when things change).

Mini example: a type fits an interface automatically

package main

import "fmt"

type Greeter interface {
	Greet() string
}

type User struct {
	Name string
}

func (u User) Greet() string {
	return "Hello, " + u.Name
}

func main() {
	var g Greeter = User{Name: "Anna"} // User satisfies Greeter
	fmt.Println(g.Greet())            // Hello, Anna
}

Note: we never wrote “User implements Greeter” anywhere. The compiler checked it for us: the Greet() string method exists, the signature matches—so everything is OK.

“Matching signature” means “matching literally”

Go is very strict here. If an interface requires:

type Closer interface {
	Close() error
}

and you wrote:

func (x File) Close() { /* ... */ }

then this is not an implementation. Even if you “conceptually” close a file. The signature differs: there is no returned error. For the compiler, that’s a different method.

4. Example: task list and a formatting interface

Let’s continue the same app storyline (as we did in previous lectures): we have tasks (Task) and we print them. Now we’ll add the ability to print tasks in different ways without rewriting all the output logic. It’s like dressing the same character differently: the data is the same, the “outfit” changes.

Data model Task

package main

type Task struct {
	ID    int
	Title string
	Done  bool
}

There’s nothing “interface-related” yet—just a regular struct.

Interface: “someone who can format a task”

Now we introduce a contract: we need someone who can turn a Task into a string.

package main

type TaskFormatter interface {
	Format(t Task) string
}

This is a very small interface: one method. And that’s great. Small interfaces are easier to implement and easier to understand.

Implementation #1: plain formatter

package main

import "fmt"

type PlainFormatter struct{}

func (PlainFormatter) Format(t Task) string {
	status := " "
	if t.Done {
		status = "x"
	}
	return fmt.Sprintf("[%s] %d: %s", status, t.ID, t.Title)
}

Notice the trick: PlainFormatter is an empty struct. That’s fine! Sometimes we don’t need to store state; we only need behavior.

Implementation #2: “debug” formatter

package main

import "fmt"

type DebugFormatter struct{}

func (DebugFormatter) Format(t Task) string {
	return fmt.Sprintf("Task{ID=%d, Title=%q, Done=%v}", t.ID, t.Title, t.Done)
}

Now we have two different implementations of the same interface.

Printing function that depends on an interface

Here’s the key part: the function that prints the task list doesn’t have to know at all which formatter it was given.

package main

import "fmt"

func PrintTasks(tasks []Task, f TaskFormatter) {
	for _, t := range tasks {
		fmt.Println(f.Format(t)) // print as the formatter decides
	}
}

This function is the “consumer” of the interface. It doesn’t care what’s inside: PlainFormatter, DebugFormatter, or anyone else you add later.

Usage in main

package main

func main() {
	tasks := []Task{
		{ID: 1, Title: "Buy milk", Done: false},
		{ID: 2, Title: "Fix a bug", Done: true},
	}

	PrintTasks(tasks, PlainFormatter{})
	// [ ] 1: Buy milk
	// [x] 2: Fix a bug

	PrintTasks(tasks, DebugFormatter{})
	// Task{ID=1, Title="Buy milk", Done=false}
	// Task{ID=2, Title="Fix a bug", Done=true}
}

And here you first feel the “taste” of interfaces: the same business logic (PrintTasks) works with different behavior implementations.

Interface as an extension point: adding new behavior

When you write code without interfaces, extension often looks like: “add one more if, one more switch, one more flag, one more branch.” Over time, the code turns into a holiday garland made of conditions.

Interfaces offer a different style: the core logic accepts an interface, and extension happens by adding new types that implement the contract. It’s like LEGO: you don’t remelt the bricks, you just attach new ones.

For example, you want to print only task titles.

package main

type TitleOnlyFormatter struct{}

func (TitleOnlyFormatter) Format(t Task) string {
	return t.Title
}

And that’s it—no need to change PrintTasks.

5. Interfaces in the standard library

It’s very useful to realize that interfaces are not “some advanced topic for architects.” This is a basic Go mechanism that the standard library is built on. You’ve already encountered interfaces; you just didn’t always notice.

error is an interface

error in Go is designed to be as simple as possible: for a type to “become an error,” it must have the Error() string method.

Mini example:

package main

import "fmt"

type ValidationError struct {
	Field string
	Msg   string
}

func (e ValidationError) Error() string {
	return fmt.Sprintf("%s: %s", e.Field, e.Msg)
}

func main() {
	var err error = ValidationError{Field: "title", Msg: "empty"}
	fmt.Println(err.Error()) // title: empty
}

In this example, ValidationError fits the error interface “by method.” No registration, inheritance, or ritual dancing.

fmt.Stringer

The fmt.Stringer interface is the contract “I can nicely turn into a string”: the String() string method.

Let’s make Task print nicely by itself.

package main

import "fmt"

func (t Task) String() string {
	status := " "
	if t.Done {
		status = "x"
	}
	return fmt.Sprintf("[%s] %d: %s", status, t.ID, t.Title)
}

After this, fmt.Println(t) will use String() (if the type satisfies fmt.Stringer). This is another example: the standard library “picks up” behavior through an interface.

6. Visual: dependencies and comparison with struct

Diagram: the “consumer” depends on the interface, not the implementation

To lock in the idea, it helps to see it as a diagram. The meaning is: the PrintTasks function knows only about the TaskFormatter interface. There can be as many implementations as you want.

flowchart LR
    A["PrintTasks(tasks, f TaskFormatter)"] --> I["TaskFormatter
Format(Task) string"] I --> P["PlainFormatter"] I --> D["DebugFormatter"] I --> T["TitleOnlyFormatter"]

Psychologically, this really declutters the code: the “main logic” isn’t cluttered with knowledge about formatting details.

Table: interface vs struct

Sometimes your brain, by habit, tries to treat an interface as “just another struct.” Let’s separate them carefully:

What we compare
struct
interface
What it describes data (fields) + may have methods behavior (methods only)
Can it store state? yes, in fields no (the interface itself has no fields)
Can you create a value with a literal? yes: Task{...} not directly; only an interface-typed variable that holds a concrete value
Main purpose modeling entities a contract between parts of the code

7. Common mistakes

Mistake #1: trying to access fields through an interface.
This is a classic: you assigned a User to a variable of an interface type and suddenly you want to do x.Name. But the interface didn’t promise the Name field; it promised only methods. The right way out is either to add a method to the interface (if that’s truly part of the contract), or not to use an interface where you need specific fields, or to design data access through methods.

Mistake #2: “the signature almost matches,” so it seems like it should work.
Go doesn’t “guess by meaning.” If an interface requires Format(Task) string, and you wrote Format(*Task) string or Format(Task) (string, error), that’s a different method. The match must be literal: names don’t matter, but parameters and results do.

Mistake #3: making the interface too large “just in case.”
A beginner really wants to assemble a “universal interface” with 10 methods so that later it will “definitely be enough.” In practice, you get the opposite: any type that wants to “fit” must implement all 10 methods, even if it doesn’t need 9 of them. It’s better to start with a minimal contract, like our one-method TaskFormatter.

Mistake #4: confusing interface and implementation and trying to “create an interface.”
An interface is not a concrete thing you can “initialize” like interface{...}{...} (you can’t). You initialize a concrete type (PlainFormatter{}), and an interface variable is a box where you put a value of a compatible type.

Mistake #5: assuming interfaces are always needed.
Interfaces are a powerful tool, but not mandatory. If you have one implementation and you don’t see a second one, sometimes it’s simpler to pass a concrete type and not complicate the code. An interface is useful when it truly simplifies the dependency: separates “what is needed” from “how it’s done,” like in the task formatting example.

1
Task
Go SELF, level 24, lesson 0
Locked
Welcoming a newcomer
Welcoming a newcomer
1
Task
Go SELF, level 24, lesson 0
Locked
Sum of readings
Sum of readings
1
Task
Go SELF, level 24, lesson 0
Locked
Output mode
Output mode
1
Task
Go SELF, level 24, lesson 0
Locked
Parsing error
Parsing error
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION